Message Scroller
A chat scroll container that anchors turns, follows streamed responses, preserves position when older messages load, and jumps to the latest message.
Chat window
A full chat window: an Empty state until the first message, then a scrolling transcript that follows the live edge. Type and press send — the reply lands a beat later and the view stays pinned to the bottom. Scroll up mid-thread and the jump-to-latest button appears. (The send/reply loop is a docs-only demo harness; in a real app an ActionCable or streaming source would produce the rows.)
Interactive chat
New Chat
How can I help you today?
div( class: "mx-auto w-full max-w-sm", data: { controller: "message-scroller-chat", message_scroller_chat_replies_value: [ "Wrap your message list in MessageScroller and turn on autoScroll — the viewport pins to the bottom as tokens arrive.", "Set scroll_anchor on the turn that should settle near the top, and it keeps a peek of the previous exchange above it.", "Auto-scroll only runs when you are already at the bottom. Scroll up and your place stays put while new messages arrive below.", "When there is unseen content the jump-to-latest button appears — one tap returns you to the newest message." ].to_json } ) do Card(class: "flex flex-col h-[32rem] gap-0 overflow-hidden") do CardHeader(class: "gap-1 border-b pb-4") do CardTitle { "New Chat" } CardDescription { "How can I help you today?" } end CardContent(class: "flex-1 overflow-hidden p-0") do div(data: {message_scroller_chat_target: "empty"}, class: "h-full") do Empty(class: "h-full border-none") do EmptyHeader do EmptyMedia(variant: :icon) do svg(xmlns: "http://www.w3.org/2000/svg", fill: "none", viewbox: "0 0 24 24", stroke_width: "1.5", stroke: "currentColor", class: "size-6") do |s| s.path(stroke_linecap: "round", stroke_linejoin: "round", d: "M8.625 9.75a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z") end end EmptyTitle { "Morning!" } EmptyDescription { "What are we working on today? Press send to start a new conversation." } end end end div(data: {message_scroller_chat_target: "scroller"}, class: "hidden h-full") do MessageScrollerProvider(auto_scroll: true) do MessageScroller do MessageScrollerViewport do MessageScrollerContent(class: "p-4", data: {message_scroller_chat_target: "content"}) end MessageScrollerButton() end end end end CardFooter(class: "border-t pt-4") do form(data: {action: "submit->message-scroller-chat#send"}, class: "flex w-full items-center gap-2") do Input(type: "text", name: "message", placeholder: "Type a message…", autocomplete: "off", class: "flex-1", data: {message_scroller_chat_target: "input"}) Button(type: "submit", class: "shrink-0") do svg(xmlns: "http://www.w3.org/2000/svg", fill: "none", viewbox: "0 0 24 24", stroke_width: "2", stroke: "currentColor", class: "size-4") do |s| s.path(stroke_linecap: "round", stroke_linejoin: "round", d: "M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18") end span(class: "sr-only") { "Send" } end end end end template(data: {message_scroller_chat_target: "userTemplate"}) do MessageScrollerItem(scroll_anchor: true) do Message(align: :end) do MessageAvatar { Avatar(size: :sm) { AvatarFallback { "ME" } } } MessageContent { Bubble { BubbleContent { "" } } } end end end template(data: {message_scroller_chat_target: "assistantTemplate"}) do MessageScrollerItem do Message do MessageAvatar { Avatar(size: :sm) { AvatarFallback { "AI" } } } MessageContent { Bubble(variant: :muted) { BubbleContent { "" } } } end end end end
Usage
MessageScroller fills its parent, so place it inside a height-constrained container. It follows the live edge while you are pinned to the bottom and releases the moment you scroll up. Scroll up in the panel below — a jump-to-latest button appears.
Streaming chat
turns = [ {role: :user, name: "ME", text: "The scroll behavior in my chat is driving me nuts. Every time the AI streams a reply, the whole thread jumps around."}, {role: :assistant, name: "AI", text: "Wrap your message list in MessageScroller and turn on autoScroll — the viewport pins to the bottom as tokens arrive, so the latest text lands in place."}, {role: :user, name: "ME", text: "But when someone sends a new message the view feels jarring, like the conversation reloads from the top."}, {role: :assistant, name: "AI", text: "MessageScrollerItem fixes that with turn anchoring. Set scrollAnchor on the turn that should settle near the top, and it leaves a peek of the previous exchange above it."}, {role: :user, name: "ME", text: "And if they scrolled up to re-read an older answer?"}, {role: :assistant, name: "AI", text: "You won't yank them back. Auto-scroll only runs when the viewport is already at the bottom. When there is unseen content, the scroll button appears — one tap returns to the newest message."} ] MessageScrollerProvider(auto_scroll: true) do div(class: "h-96 w-full rounded-xl border bg-background") do MessageScroller do MessageScrollerViewport do MessageScrollerContent(class: "p-4") do turns.each do |turn| MessageScrollerItem(scroll_anchor: turn[:role] == :user) do Message(align: turn[:role] == :user ? :end : :start) do MessageAvatar do Avatar(size: :sm) { AvatarFallback { turn[:name] } } end MessageContent do Bubble(variant: turn[:role] == :user ? :default : :muted) do BubbleContent { turn[:text] } end end end end end end end MessageScrollerButton() end end end
Anchoring turns
Mark the row that starts a new turn with scroll_anchor. When it is appended, the viewport moves it near the top and keeps a peek of the previous item above it, so the new turn does not feel detached.
Anchored user turn
MessageScrollerProvider do div(class: "h-80 w-full rounded-xl border bg-background") do MessageScroller do MessageScrollerViewport do MessageScrollerContent(class: "p-4") do MessageScrollerItem(scroll_anchor: true) do Message(align: :end) do MessageAvatar { Avatar(size: :sm) { AvatarFallback { "ME" } } } MessageContent { Bubble { BubbleContent { "Can you summarize the deploy?" } } } end end MessageScrollerItem do Message do MessageAvatar { Avatar(size: :sm) { AvatarFallback { "AI" } } } MessageContent { Bubble(variant: :muted) { BubbleContent { "Shipped 3 PRs: the bubble surface, the message layout, and the scroller. All green." } } } end end end end MessageScrollerButton() end end end
Scroll commands
The provider owns the scroll state, so controls placed anywhere inside it can drive the viewport. These buttons call the controller's scrollToStart and scrollToEnd actions directly.
Jump to start or end
MessageScrollerProvider(auto_scroll: false) do div(class: "space-y-2") do div(class: "flex gap-2") do Button(variant: :outline, size: :sm, data: {action: "click->ruby-ui--message-scroller#scrollToStart"}) { "Jump to start" } Button(variant: :outline, size: :sm, data: {action: "click->ruby-ui--message-scroller#scrollToEnd"}) { "Jump to latest" } end div(class: "h-72 w-full rounded-xl border bg-background") do MessageScroller do MessageScrollerViewport do MessageScrollerContent(class: "p-4") do 6.times do |i| MessageScrollerItem do Message(align: i.odd? ? :end : :start) do MessageAvatar { Avatar(size: :sm) { AvatarFallback { i.odd? ? "ME" : "AI" } } } MessageContent { Bubble(variant: i.odd? ? :default : :muted) { BubbleContent { "Message number #{i + 1} in a longer thread." } } } end end end end end MessageScrollerButton() end end end end
Installation
Using RubyUI CLI
Run the install command
rails g ruby_ui:component MessageScroller
Manual installation
1
Add RubyUI::MessageScroller to app/components/ruby_ui/message_scroller/message_scroller.rb
# frozen_string_literal: true module RubyUI class MessageScroller < Base def view_template(&) div(**attrs, &) end private def default_attrs { data: {slot: "message-scroller"}, class: "group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden" } end end end
2
Add RubyUI::MessageScrollerButton to app/components/ruby_ui/message_scroller/message_scroller_button.rb
# frozen_string_literal: true module RubyUI class MessageScrollerButton < Base def initialize(direction: :end, **attrs) @direction = direction super(**attrs) end def view_template(&) button(**attrs) do if block_given? yield else default_icon span(class: "sr-only") { (@direction == :start) ? "Scroll to start" : "Scroll to end" } end end end private def default_attrs { type: "button", tabindex: "-1", data: { slot: "message-scroller-button", direction: @direction, active: "false", ruby_ui__message_scroller_target: "button", action: "click->ruby-ui--message-scroller#jump" }, class: "absolute left-1/2 z-10 -translate-x-1/2 inline-flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full data-[direction=start]:[&_svg]:rotate-180" } end def default_icon svg( xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", class: "size-4" ) do |s| s.path(d: "M12 5v14") s.path(d: "m19 12-7 7-7-7") end end end end
3
Add RubyUI::MessageScrollerContent to app/components/ruby_ui/message_scroller/message_scroller_content.rb
# frozen_string_literal: true module RubyUI class MessageScrollerContent < Base def view_template(&) div(**attrs, &) end private def default_attrs { role: "log", aria_relevant: "additions text", data: { slot: "message-scroller-content", ruby_ui__message_scroller_target: "content" }, class: "flex h-max min-h-full flex-col gap-8" } end end end
4
Add RubyUI::MessageScrollerDocs to app/components/ruby_ui/message_scroller/message_scroller_docs.rb
# frozen_string_literal: true class Views::Docs::MessageScroller < Views::Base def view_template component = "MessageScroller" div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do render Docs::Header.new(title: "Message Scroller", description: "A chat scroll container that anchors turns, follows streamed responses, preserves position when older messages load, and jumps to the latest message.") Heading(level: 2) { "Chat window" } Text(class: "text-muted-foreground") { "A full chat window: an Empty state until the first message, then a scrolling transcript that follows the live edge. Type and press send — the reply lands a beat later and the view stays pinned to the bottom. Scroll up mid-thread and the jump-to-latest button appears. (The send/reply loop is a docs-only demo harness; in a real app an ActionCable or streaming source would produce the rows.)" } render Docs::VisualCodeExample.new(title: "Interactive chat", context: self) do <<~RUBY div( class: "mx-auto w-full max-w-sm", data: { controller: "message-scroller-chat", message_scroller_chat_replies_value: [ "Wrap your message list in MessageScroller and turn on autoScroll — the viewport pins to the bottom as tokens arrive.", "Set scroll_anchor on the turn that should settle near the top, and it keeps a peek of the previous exchange above it.", "Auto-scroll only runs when you are already at the bottom. Scroll up and your place stays put while new messages arrive below.", "When there is unseen content the jump-to-latest button appears — one tap returns you to the newest message." ].to_json } ) do Card(class: "flex flex-col h-[32rem] gap-0 overflow-hidden") do CardHeader(class: "gap-1 border-b pb-4") do CardTitle { "New Chat" } CardDescription { "How can I help you today?" } end CardContent(class: "flex-1 overflow-hidden p-0") do div(data: {message_scroller_chat_target: "empty"}, class: "h-full") do Empty(class: "h-full border-none") do EmptyHeader do EmptyMedia(variant: :icon) do svg(xmlns: "http://www.w3.org/2000/svg", fill: "none", viewbox: "0 0 24 24", stroke_width: "1.5", stroke: "currentColor", class: "size-6") do |s| s.path(stroke_linecap: "round", stroke_linejoin: "round", d: "M8.625 9.75a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z") end end EmptyTitle { "Morning!" } EmptyDescription { "What are we working on today? Press send to start a new conversation." } end end end div(data: {message_scroller_chat_target: "scroller"}, class: "hidden h-full") do MessageScrollerProvider(auto_scroll: true) do MessageScroller do MessageScrollerViewport do MessageScrollerContent(class: "p-4", data: {message_scroller_chat_target: "content"}) end MessageScrollerButton() end end end end CardFooter(class: "border-t pt-4") do form(data: {action: "submit->message-scroller-chat#send"}, class: "flex w-full items-center gap-2") do Input(type: "text", name: "message", placeholder: "Type a message…", autocomplete: "off", class: "flex-1", data: {message_scroller_chat_target: "input"}) Button(type: "submit", class: "shrink-0") do svg(xmlns: "http://www.w3.org/2000/svg", fill: "none", viewbox: "0 0 24 24", stroke_width: "2", stroke: "currentColor", class: "size-4") do |s| s.path(stroke_linecap: "round", stroke_linejoin: "round", d: "M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18") end span(class: "sr-only") { "Send" } end end end end template(data: {message_scroller_chat_target: "userTemplate"}) do MessageScrollerItem(scroll_anchor: true) do Message(align: :end) do MessageAvatar { Avatar(size: :sm) { AvatarFallback { "ME" } } } MessageContent { Bubble { BubbleContent { "" } } } end end end template(data: {message_scroller_chat_target: "assistantTemplate"}) do MessageScrollerItem do Message do MessageAvatar { Avatar(size: :sm) { AvatarFallback { "AI" } } } MessageContent { Bubble(variant: :muted) { BubbleContent { "" } } } end end end end RUBY end Heading(level: 2) { "Usage" } Text(class: "text-muted-foreground") { "MessageScroller fills its parent, so place it inside a height-constrained container. It follows the live edge while you are pinned to the bottom and releases the moment you scroll up. Scroll up in the panel below — a jump-to-latest button appears." } render Docs::VisualCodeExample.new(title: "Streaming chat", context: self) do <<~RUBY turns = [ {role: :user, name: "ME", text: "The scroll behavior in my chat is driving me nuts. Every time the AI streams a reply, the whole thread jumps around."}, {role: :assistant, name: "AI", text: "Wrap your message list in MessageScroller and turn on autoScroll — the viewport pins to the bottom as tokens arrive, so the latest text lands in place."}, {role: :user, name: "ME", text: "But when someone sends a new message the view feels jarring, like the conversation reloads from the top."}, {role: :assistant, name: "AI", text: "MessageScrollerItem fixes that with turn anchoring. Set scrollAnchor on the turn that should settle near the top, and it leaves a peek of the previous exchange above it."}, {role: :user, name: "ME", text: "And if they scrolled up to re-read an older answer?"}, {role: :assistant, name: "AI", text: "You won't yank them back. Auto-scroll only runs when the viewport is already at the bottom. When there is unseen content, the scroll button appears — one tap returns to the newest message."} ] MessageScrollerProvider(auto_scroll: true) do div(class: "h-96 w-full rounded-xl border bg-background") do MessageScroller do MessageScrollerViewport do MessageScrollerContent(class: "p-4") do turns.each do |turn| MessageScrollerItem(scroll_anchor: turn[:role] == :user) do Message(align: turn[:role] == :user ? :end : :start) do MessageAvatar do Avatar(size: :sm) { AvatarFallback { turn[:name] } } end MessageContent do Bubble(variant: turn[:role] == :user ? :default : :muted) do BubbleContent { turn[:text] } end end end end end end end MessageScrollerButton() end end end RUBY end Heading(level: 2) { "Anchoring turns" } Text(class: "text-muted-foreground") { "Mark the row that starts a new turn with scroll_anchor. When it is appended, the viewport moves it near the top and keeps a peek of the previous item above it, so the new turn does not feel detached." } render Docs::VisualCodeExample.new(title: "Anchored user turn", context: self) do <<~RUBY MessageScrollerProvider do div(class: "h-80 w-full rounded-xl border bg-background") do MessageScroller do MessageScrollerViewport do MessageScrollerContent(class: "p-4") do MessageScrollerItem(scroll_anchor: true) do Message(align: :end) do MessageAvatar { Avatar(size: :sm) { AvatarFallback { "ME" } } } MessageContent { Bubble { BubbleContent { "Can you summarize the deploy?" } } } end end MessageScrollerItem do Message do MessageAvatar { Avatar(size: :sm) { AvatarFallback { "AI" } } } MessageContent { Bubble(variant: :muted) { BubbleContent { "Shipped 3 PRs: the bubble surface, the message layout, and the scroller. All green." } } } end end end end MessageScrollerButton() end end end RUBY end Heading(level: 2) { "Scroll commands" } Text(class: "text-muted-foreground") { "The provider owns the scroll state, so controls placed anywhere inside it can drive the viewport. These buttons call the controller's scrollToStart and scrollToEnd actions directly." } render Docs::VisualCodeExample.new(title: "Jump to start or end", context: self) do <<~RUBY MessageScrollerProvider(auto_scroll: false) do div(class: "space-y-2") do div(class: "flex gap-2") do Button(variant: :outline, size: :sm, data: {action: "click->ruby-ui--message-scroller#scrollToStart"}) { "Jump to start" } Button(variant: :outline, size: :sm, data: {action: "click->ruby-ui--message-scroller#scrollToEnd"}) { "Jump to latest" } end div(class: "h-72 w-full rounded-xl border bg-background") do MessageScroller do MessageScrollerViewport do MessageScrollerContent(class: "p-4") do 6.times do |i| MessageScrollerItem do Message(align: i.odd? ? :end : :start) do MessageAvatar { Avatar(size: :sm) { AvatarFallback { i.odd? ? "ME" : "AI" } } } MessageContent { Bubble(variant: i.odd? ? :default : :muted) { BubbleContent { "Message number \#{i + 1} in a longer thread." } } } end end end end end MessageScrollerButton() end end end end RUBY end render Components::ComponentSetup::Tabs.new(component_name: component) # components render Docs::ComponentsTable.new(component_files(component)) end end end
5
Add RubyUI::MessageScrollerItem to app/components/ruby_ui/message_scroller/message_scroller_item.rb
# frozen_string_literal: true module RubyUI class MessageScrollerItem < Base def initialize(scroll_anchor: false, message_id: nil, **attrs) @scroll_anchor = scroll_anchor @message_id = message_id super(**attrs) end def view_template(&) div(**attrs, &) end private def default_attrs data = {slot: "message-scroller-item"} data[:scroll_anchor] = "" if @scroll_anchor data[:message_id] = @message_id if @message_id { data: data, class: "min-w-0 shrink-0" } end end end
6
Add RubyUI::MessageScrollerProvider to app/components/ruby_ui/message_scroller/message_scroller_provider.rb
# frozen_string_literal: true module RubyUI class MessageScrollerProvider < Base def initialize(auto_scroll: true, previous_item_peek: 64, default_position: :end, preserve_on_prepend: true, **attrs) @auto_scroll = auto_scroll @previous_item_peek = previous_item_peek @default_position = default_position @preserve_on_prepend = preserve_on_prepend super(**attrs) end def view_template(&) div(**attrs, &) end private def default_attrs { data: { slot: "message-scroller-provider", controller: "ruby-ui--message-scroller", ruby_ui__message_scroller_auto_scroll_value: @auto_scroll.to_s, ruby_ui__message_scroller_previous_item_peek_value: @previous_item_peek, ruby_ui__message_scroller_default_position_value: @default_position, ruby_ui__message_scroller_preserve_on_prepend_value: @preserve_on_prepend.to_s }, # display: contents — the provider owns scroll state without adding a box. class: "contents" } end end end
7
Add RubyUI::MessageScrollerViewport to app/components/ruby_ui/message_scroller/message_scroller_viewport.rb
# frozen_string_literal: true module RubyUI class MessageScrollerViewport < Base def view_template(&) div(**attrs, &) end private def default_attrs { tabindex: "0", data: { slot: "message-scroller-viewport", ruby_ui__message_scroller_target: "viewport" }, class: "size-full min-h-0 min-w-0 overflow-y-auto overscroll-contain contain-content" } end end end
8
Add message_scroller_controller.js to app/javascript/controllers/ruby_ui/message_scroller_controller.js
import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="ruby-ui--message-scroller" // // A chat transcript scroller. Owns scroll state and behavior for a // height-constrained message list: // // - autoScroll: follows the live edge while the reader is pinned to the // bottom, and releases the moment they scroll, wheel, drag, or key away. // - scrollAnchor: when a new anchored turn is appended, settles it near the // top of the viewport keeping a peek of the previous turn above it. // - defaultScrollPosition: where a freshly mounted transcript opens // ("end", "start" or "last-anchor"). // - preserveScrollOnPrepend: keeps the visible row fixed when older messages // are loaded in above the current view. // // Public API (callable from other controllers/outlets or future // streaming/ActionCable code): scrollToEnd(), scrollToStart(), // scrollToMessage(id). New rows appended to the content target are picked up // automatically via MutationObserver — no manual call needed. export default class extends Controller { static targets = ["viewport", "content", "button"]; static values = { autoScroll: { type: Boolean, default: true }, previousItemPeek: { type: Number, default: 64 }, defaultPosition: { type: String, default: "end" }, preserveOnPrepend: { type: Boolean, default: true }, endThreshold: { type: Number, default: 32 }, }; connect() { // Reader is considered "following" the live edge until they move away. this.following = true; // True only while a programmatic scroll is in flight, so reader-intent // handlers don't mistake our own scrolling for the reader's. this.programmatic = false; this.onScroll = this.onScroll.bind(this); this.onWheel = this.onWheel.bind(this); this.onTouchStart = this.onTouchStart.bind(this); this.onKeydown = this.onKeydown.bind(this); if (this.hasViewportTarget) { this.viewportTarget.addEventListener("scroll", this.onScroll, { passive: true }); this.viewportTarget.addEventListener("wheel", this.onWheel, { passive: true }); this.viewportTarget.addEventListener("touchstart", this.onTouchStart, { passive: true }); this.viewportTarget.addEventListener("keydown", this.onKeydown); } if (this.hasContentTarget) { // Announce streamed/added messages to assistive tech at a calm pace. if (!this.contentTarget.hasAttribute("role")) { this.contentTarget.setAttribute("role", "log"); } if (!this.contentTarget.hasAttribute("aria-relevant")) { this.contentTarget.setAttribute("aria-relevant", "additions text"); } this.observer = new MutationObserver((records) => this.onMutations(records)); this.observer.observe(this.contentTarget, { childList: true, subtree: true, characterData: true, }); } // Apply the opening position after layout settles. requestAnimationFrame(() => { this.applyDefaultPosition(); this.updateButton(); }); } disconnect() { if (this.hasViewportTarget) { this.viewportTarget.removeEventListener("scroll", this.onScroll); this.viewportTarget.removeEventListener("wheel", this.onWheel); this.viewportTarget.removeEventListener("touchstart", this.onTouchStart); this.viewportTarget.removeEventListener("keydown", this.onKeydown); } this.observer?.disconnect(); if (this.animationFrame) cancelAnimationFrame(this.animationFrame); } // --- Reader intent ------------------------------------------------------- onScroll() { if (this.programmatic) return; this.following = this.isAtEnd(); this.updateButton(); } // Any upward wheel is a deliberate move away from the live edge. onWheel(event) { if (event.deltaY < 0) this.release(); } onTouchStart() { // A touch that turns into an upward drag surfaces through onScroll; this // just makes the release feel immediate when the reader grabs the list. if (!this.isAtEnd()) this.release(); } onKeydown(event) { const navKeys = ["ArrowUp", "PageUp", "Home", "ArrowDown", "PageDown", "End", " "]; if (navKeys.includes(event.key)) this.release(); } release() { if (this.programmatic) return; this.following = false; } // --- Mutations (new / prepended / streamed rows) ------------------------- onMutations(records) { let appended = null; let prependedHeight = 0; let streamed = false; const gap = this.rowGap(); for (const record of records) { // Text streamed into an existing row (e.g. tokens) — not a new turn. if (record.type === "characterData") { streamed = true; continue; } if (record.type !== "childList") continue; // Only direct children of the content element are transcript rows. // Markup inserted *inside* a message must not be mistaken for history. if (record.target !== this.contentTarget) { streamed = true; continue; } for (const node of record.addedNodes) { if (node.nodeType !== Node.ELEMENT_NODE) continue; if (record.previousSibling === null && record.nextSibling !== null) { // Inserted above existing rows → history prepend. Account for the // flex row gap each prepended row introduces, or the preserved row // drifts down by one gap per insertion. prependedHeight += (node.offsetHeight || 0) + gap; } else { // Inserted at (or after) the end → new turn. appended = node; } } } if (prependedHeight > 0 && this.preserveOnPrependValue) { // Keep the reader's current row fixed while history loads in above. this.viewportTarget.scrollTop += prependedHeight; } // Only move for new/streamed content while the reader is at the live edge. // If they scrolled away, leave them there and let the button surface it. const follow = this.autoScrollValue && this.following; if (appended && follow) { const anchor = appended.matches?.("[data-scroll-anchor]") ? appended : appended.querySelector?.("[data-scroll-anchor]"); if (anchor) { this.scrollToAnchor(anchor); } else { this.scrollToEnd(); } } else if (!appended && streamed && follow) { // Text streamed into the last row. Stay pinned. this.scrollToEnd("auto"); } this.updateButton(); } rowGap() { if (!this.hasContentTarget) return 0; const value = parseFloat(getComputedStyle(this.contentTarget).rowGap); return Number.isFinite(value) ? value : 0; } // --- Public scroll commands --------------------------------------------- scrollToEnd(behavior = "smooth") { if (!this.hasViewportTarget) return; this.following = true; this.scrollTo(this.viewportTarget.scrollHeight, behavior); } scrollToStart(behavior = "smooth") { if (!this.hasViewportTarget) return; this.following = false; this.scrollTo(0, behavior); } // Scroll a row with a matching messageId into view. Returns false when the // target is not mounted. scrollToMessage(id, behavior = "smooth") { if (!this.hasContentTarget) return false; const item = this.contentTarget.querySelector(`[data-message-id="${CSS.escape(id)}"]`); if (!item) return false; this.following = false; this.scrollToAnchor(item, behavior); return true; } scrollToAnchor(item, behavior = "smooth") { const top = Math.max(0, item.offsetTop - this.previousItemPeekValue); this.scrollTo(top, behavior); } // Bound to the scroll button's click action. Honors the button's // data-direction so a start-direction button jumps to the start. jump(event) { if (event?.currentTarget?.dataset.direction === "start") { this.scrollToStart(); } else { this.scrollToEnd(); } } // --- Internals ----------------------------------------------------------- // Native scrollTo({ behavior: "smooth" }) is unreliable on a contained, // virtualized viewport, so we animate scrollTop ourselves with rAF. This // gives us full control over completion (no scrollend dependency) and lets // us honor reduced-motion. scrollTo(top, behavior = "smooth") { if (!this.hasViewportTarget) return; const max = this.viewportTarget.scrollHeight - this.viewportTarget.clientHeight; const target = Math.max(0, Math.min(top, max)); this.programmatic = true; this.element.setAttribute("data-autoscrolling", ""); this.viewportTarget.setAttribute("data-autoscrolling", ""); if (this.animationFrame) cancelAnimationFrame(this.animationFrame); if (behavior === "auto" || this.prefersReducedMotion()) { this.viewportTarget.scrollTop = target; this.finishScroll(); return; } const start = this.viewportTarget.scrollTop; const distance = target - start; const duration = 300; let startTime = null; const step = (now) => { if (startTime === null) startTime = now; const t = Math.min(1, (now - startTime) / duration); // easeOutCubic const eased = 1 - Math.pow(1 - t, 3); this.viewportTarget.scrollTop = start + distance * eased; if (t < 1) { this.animationFrame = requestAnimationFrame(step); } else { this.finishScroll(); } }; this.animationFrame = requestAnimationFrame(step); } finishScroll() { this.programmatic = false; this.element.removeAttribute("data-autoscrolling"); this.viewportTarget?.removeAttribute("data-autoscrolling"); this.following = this.isAtEnd(); this.updateButton(); } prefersReducedMotion() { return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } applyDefaultPosition() { if (!this.hasViewportTarget) return; const position = this.defaultPositionValue; if (position === "start") { this.following = false; this.viewportTarget.scrollTop = 0; return; } if (position === "last-anchor") { // Stimulus' contentTarget getter throws when missing — guard explicitly. const anchors = this.hasContentTarget ? this.contentTarget.querySelectorAll("[data-scroll-anchor]") : []; const last = anchors[anchors.length - 1]; // Fall back to the end when there's no anchor, or the last turn already // fits in the viewport. if (last && last.offsetTop - this.previousItemPeekValue > 0) { this.following = false; this.viewportTarget.scrollTop = Math.max(0, last.offsetTop - this.previousItemPeekValue); this.updateButton(); return; } } // Default: open at the live edge. this.following = true; this.viewportTarget.scrollTop = this.viewportTarget.scrollHeight; } isAtEnd() { if (!this.hasViewportTarget) return true; const { scrollTop, clientHeight, scrollHeight } = this.viewportTarget; return scrollHeight - (scrollTop + clientHeight) <= this.endThresholdValue; } isAtStart() { if (!this.hasViewportTarget) return true; return this.viewportTarget.scrollTop <= this.endThresholdValue; } hasOverflow() { if (!this.hasViewportTarget) return false; return this.viewportTarget.scrollHeight - this.viewportTarget.clientHeight > this.endThresholdValue; } // Each button activates based on its own direction: an end button when the // reader is away from the bottom, a start button when away from the top. updateButton() { if (!this.hasButtonTarget) return; const overflow = this.hasOverflow(); this.buttonTargets.forEach((button) => { const toStart = button.dataset.direction === "start"; const active = overflow && (toStart ? !this.isAtStart() : !this.isAtEnd()); button.setAttribute("data-active", active ? "true" : "false"); // Remove the inert button from the tab order so there are no ghost stops. button.setAttribute("tabindex", active ? "0" : "-1"); }); } }
9
Update the Stimulus controllers manifest file
Importmap!
rake stimulus:manifest:update
10
Install required components
Component MessageScroller relies on the following RubyUI components. Refer to their individual installation guides for setup instructions:
Components
| Component | Built using | Source |
|---|---|---|
MessageScroller | Phlex | |
MessageScrollerButton | Phlex | |
MessageScrollerContent | Phlex | |
MessageScrollerDocs | Phlex | |
MessageScrollerItem | Phlex | |
MessageScrollerProvider | Phlex | |
MessageScrollerViewport | Phlex | |
MessageScrollerController | Stimulus JS |