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
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. With auto_scroll: true it follows the live edge while you are pinned to the bottom and releases the moment you scroll up (auto-scroll is off by default, as in shadcn). 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
Loading older messages
Insert older rows at the top of MessageScrollerContent and the reader's row stays where it was: the browser's scroll anchoring keeps it in place. Pass preserve_scroll_on_prepend: false to MessageScrollerViewport to turn that off.
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
# Jumps to the end (or start) of the transcript. Styled as a RubyUI Button
# (shadcn renders <Button variant="secondary" size="icon-sm">); variant: and
# size: take the same values as Button. Inactive while the reader is
# already at that edge: hidden, inert and out of the tab order.
#
# behavior: :smooth (default) or :auto (jump at once).
# shadcn's render prop has no counterpart; pass a block for custom content.
class MessageScrollerButton < Base
CLASSES = [
"absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground",
"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=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)]",
"data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100",
"data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)]",
"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",
"rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180"
].freeze
def initialize(direction: :end, behavior: :smooth, variant: :secondary, size: :icon_sm, **attrs)
@direction = direction.to_s
@behavior = behavior.to_s
@variant = variant
@size = size
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",
# Inactive until the controller measures the viewport.
inert: true,
data: {
slot: "message-scroller-button",
direction: @direction,
behavior: @behavior,
variant: @variant.to_s.tr("_", "-"),
size: @size.to_s.tr("_", "-"),
active: "false",
ruby_ui__message_scroller_target: "button",
ruby_ui__message_scroller_direction_param: @direction,
ruby_ui__message_scroller_behavior_param: @behavior,
action: "click->ruby-ui--message-scroller#jump"
},
class: [Button.new(variant: @variant, size: @size).attrs[:class], *CLASSES]
}
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",
aria_hidden: "true"
) 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
# The transcript: a polite live region that announces new rows (not every
# streamed character, hence aria-relevant="additions", as in shadcn).
#
# Ends with a hidden spacer the controller sizes when an anchored turn needs
# room to settle near the top while its reply is still short.
# spacer_class: styles it (shadcn's spacerClassName).
class MessageScrollerContent < Base
def initialize(spacer_class: nil, **attrs)
@spacer_class = spacer_class
super(**attrs)
end
def view_template(&block)
div(**attrs) do
yield if block
div(
aria_hidden: "true",
hidden: true,
class: @spacer_class,
data: {message_scroller_spacer: "", ruby_ui__message_scroller_target: "spacer"}
)
end
end
private
def default_attrs
{
role: "log",
aria_relevant: "additions",
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. With auto_scroll: true it follows the live edge while you are pinned to the bottom and releases the moment you scroll up (auto-scroll is off by default, as in shadcn). 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) { "Loading older messages" }
Text(class: "text-muted-foreground") { "Insert older rows at the top of MessageScrollerContent and the reader's row stays where it was: the browser's scroll anchoring keeps it in place. Pass preserve_scroll_on_prepend: false to MessageScrollerViewport to turn that off." }
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", scroll_anchor: @scroll_anchor ? "true" : "false"}
data[:message_id] = @message_id if @message_id
{
data: data,
# content-visibility skips rendering rows that are off screen; the
# intrinsic size keeps the scrollbar stable meanwhile.
class: "min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]"
}
end
end
end
6
Add RubyUI::MessageScrollerProvider to app/components/ruby_ui/message_scroller/message_scroller_provider.rb
# frozen_string_literal: true
module RubyUI
# Owns the scroll state (shadcn's MessageScroller.Provider). Unlike the React
# provider it renders an element, display: contents, because Stimulus needs
# one to hold the controller.
#
# auto_scroll, default_position, scroll_edge_threshold, previous_item_peek
# and scroll_margin mirror shadcn's autoScroll, defaultScrollPosition,
# scrollEdgeThreshold, scrollPreviousItemPeek and scrollMargin, with the
# same defaults.
class MessageScrollerProvider < Base
def initialize(auto_scroll: false, default_position: :end, scroll_edge_threshold: 8, previous_item_peek: 64,
scroll_margin: 0, preserve_on_prepend: nil, **attrs)
@auto_scroll = auto_scroll
@default_position = default_position
@scroll_edge_threshold = scroll_edge_threshold
@previous_item_peek = previous_item_peek
@scroll_margin = scroll_margin
# preserve_on_prepend: is the deprecated name of
# MessageScrollerViewport's preserve_scroll_on_prepend:.
unless preserve_on_prepend.nil?
Warning.warn("RubyUI::MessageScrollerProvider: `preserve_on_prepend:` is deprecated, " \
"use `MessageScrollerViewport(preserve_scroll_on_prepend:)`\n", category: :deprecated)
end
@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_default_position_value: @default_position,
ruby_ui__message_scroller_scroll_edge_threshold_value: @scroll_edge_threshold,
ruby_ui__message_scroller_previous_item_peek_value: @previous_item_peek,
ruby_ui__message_scroller_scroll_margin_value: @scroll_margin
}
data[:ruby_ui__message_scroller_preserve_on_prepend_value] = @preserve_on_prepend.to_s unless @preserve_on_prepend.nil?
{data: data, 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
# The scroll container. A named region (aria-label "Messages", override with
# aria: {label: ...}) so the transcript is announced when focused.
#
# preserve_scroll_on_prepend: keeps the visible row in place when older rows
# load above it. The browser does this natively (CSS scroll anchoring);
# false turns it off with overflow-anchor: none.
#
# data-pending-scroll hides the first paint until the controller has moved
# to the default position, so the transcript doesn't flash at the top.
class MessageScrollerViewport < Base
CLASSES = [
"size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable",
"overflow-y-auto overscroll-contain contain-content",
"data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent",
"data-pending-scroll:invisible"
].freeze
def initialize(preserve_scroll_on_prepend: true, **attrs)
@preserve_scroll_on_prepend = preserve_scroll_on_prepend
super(**attrs)
end
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
role: "region",
aria: {label: "Messages"},
tabindex: "0",
data: {
slot: "message-scroller-viewport",
pending_scroll: "",
ruby_ui__message_scroller_target: "viewport"
},
class: [*CLASSES, ("[overflow-anchor:none]" unless @preserve_scroll_on_prepend)]
}
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, after shadcn's MessageScroller. Owns scroll
// state and behavior for a height-constrained message list:
//
// - autoScroll (off by default, as in shadcn): 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. The
// content's spacer gives it room while the reply is still short.
// - defaultPosition: where a freshly mounted transcript opens
// ("end", "start" or "last-anchor").
// - Prepending older rows keeps the visible row in place through the
// browser's own scroll anchoring (CSS overflow-anchor), not here.
//
// State for styling: data-scrollable ("start", "end", "start end") on the
// root and viewport, data-autoscrolling while a programmatic scroll runs,
// data-pending-scroll on the viewport until the first positioning.
//
// 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", "spacer", "button"];
static values = {
autoScroll: { type: Boolean, default: false },
defaultPosition: { type: String, default: "end" },
scrollEdgeThreshold: { type: Number, default: 8 },
previousItemPeek: { type: Number, default: 64 },
scrollMargin: { type: Number, default: 0 },
// Deprecated: MessageScrollerViewport(preserve_scroll_on_prepend:).
preserveOnPrepend: Boolean,
};
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;
// The anchored row held near the top, if any, and where it was put.
this.anchored = null;
this.heldTop = null;
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.hasPreserveOnPrependValue && !this.preserveOnPrependValue) {
this.viewportTarget.style.overflowAnchor = "none";
}
}
// A blank copy of the spacer, to put back if the rows are replaced
// wholesale (e.g. a Turbo Stream update on the content).
if (this.hasSpacerTarget && !this.spacerTemplate) {
this.spacerTemplate = this.spacerTarget.cloneNode(false);
this.spacerTemplate.hidden = true;
this.spacerTemplate.removeAttribute("style");
}
if (this.hasContentTarget) {
// Announce 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");
}
this.observer = new MutationObserver((records) => this.onMutations(records));
this.observer.observe(this.contentTarget, {
childList: true,
subtree: true,
characterData: true,
});
// Rows with content-visibility: auto start at an estimated height and
// settle once rendered; keep a held anchor in place when that happens.
if (typeof ResizeObserver !== "undefined") {
this.resizeObserver = new ResizeObserver(() => this.onResize());
this.resizeObserver.observe(this.contentTarget);
if (this.hasViewportTarget) this.resizeObserver.observe(this.viewportTarget);
}
}
// Apply the opening position after layout settles.
this.positionFrame = requestAnimationFrame(() => {
this.positionFrame = null;
this.applyDefaultPosition();
if (this.hasViewportTarget) this.viewportTarget.removeAttribute("data-pending-scroll");
this.updateState();
});
}
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();
this.resizeObserver?.disconnect();
if (this.positionFrame) cancelAnimationFrame(this.positionFrame);
if (this.releaseFrame) cancelAnimationFrame(this.releaseFrame);
this.releaseFrame = null;
this.forcedRows?.forEach((row) => (row.style.contentVisibility = ""));
this.forcedRows?.clear();
// Leave no visible state behind for a reconnect (Turbo snapshot restore):
// the spacer would skew the opening position.
this.abortScroll();
this.clearSpacer();
}
// --- Reader intent -------------------------------------------------------
onScroll() {
if (this.programmatic) return;
if (this.anchored) {
// Our own correction, or the browser clamping scrollTop after a row
// settled to a smaller height: keep holding the turn.
const { scrollTop, scrollHeight, clientHeight } = this.viewportTarget;
const clamped = scrollTop < this.heldTop && scrollTop >= scrollHeight - clientHeight - 1;
if (Math.abs(scrollTop - this.heldTop) < 1 || clamped) {
this.updateState();
return;
}
// The reader moved: stop holding (the spacer stays until the next turn).
this.anchored = null;
this.heldTop = null;
}
this.following = this.isAtEnd();
this.updateState();
}
// 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();
}
// An explicit move by the reader wins over a scroll in flight: stop it and
// let go of the held turn instead of pulling them back when it ends.
release() {
if (this.programmatic) this.abortScroll();
this.following = false;
this.anchored = null;
this.heldTop = null;
}
// --- Mutations (new / prepended / streamed rows) -------------------------
onMutations(records) {
let appended = null;
let anchorRow = null;
let streamed = false;
if (!this.hasSpacerTarget && this.spacerTemplate) {
this.contentTarget.appendChild(this.spacerTemplate.cloneNode(false));
}
const spacer = this.hasSpacerTarget ? this.spacerTarget : null;
// Our own move of the spacer to the end: nothing to react to.
const onlySpacer = (record) =>
record.type === "childList" &&
[...record.addedNodes, ...record.removedNodes].every((node) => node === spacer);
if (spacer && records.every(onlySpacer)) return;
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 || node === spacer) continue;
// Inserted above existing rows → history prepend. The browser's
// scroll anchoring keeps the reader's row in place; nothing to do.
const prepended = record.previousSibling === null && record.nextSibling !== null && record.nextSibling !== spacer;
if (prepended) continue;
appended = node;
if (!anchorRow && (node.matches?.('[data-scroll-anchor="true"]') || node.querySelector?.('[data-scroll-anchor="true"]'))) {
anchorRow = node;
}
}
}
// Keep the spacer as the last child when rows land after it.
if (spacer && spacer !== this.contentTarget.lastElementChild) {
this.contentTarget.appendChild(spacer);
}
// 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) {
// Several rows in one go (a question and its answer): the first
// anchored one settles, as in shadcn.
const anchor = anchorRow?.matches('[data-scroll-anchor="true"]')
? anchorRow
: anchorRow?.querySelector('[data-scroll-anchor="true"]');
if (anchor) {
this.scrollToAnchor(anchor);
} else if (this.anchored) {
// The reply to a held turn: keep the turn in place while the spacer
// gives way to the reply. A scroll in flight re-holds when it ends.
if (!this.programmatic) this.hold();
} else {
this.scrollToEnd();
}
} else if (this.anchored) {
if (!this.programmatic) this.hold();
} else if (!appended && streamed && follow) {
// Text streamed into the last row. Stay pinned.
this.scrollToEnd("auto");
}
this.updateState();
}
onResize() {
if (this.programmatic || this.positionFrame || !this.hasViewportTarget) return;
if (this.anchored) {
this.hold();
} else if (this.autoScrollValue && this.following && !this.isAtEnd()) {
// A row grew (an image loaded, a reply re-rendered): stay pinned.
this.viewportTarget.scrollTop = this.viewportTarget.scrollHeight;
}
this.updateState();
}
// Re-sizes the spacer for the held turn and puts the turn back on its
// target. Rows with content-visibility: auto start at an estimated height,
// so the target moves once they render.
hold() {
if (!this.anchored || !this.hasViewportTarget) return;
// The held row was removed (transcript reset, Turbo replace): let go.
if (!this.anchored.isConnected) {
this.clearSpacer();
return;
}
const hadRoom = this.hasSpacerTarget && !this.spacerTarget.hidden;
const target = this.sizeSpacer(this.anchored);
// The reply has filled the room below the turn: with autoScroll on and
// the reader still following, go back to following the live edge (as
// shadcn does when its spacer reaches 0).
if (hadRoom && this.spacerTarget.hidden && this.autoScrollValue && this.following) {
this.scrollToEnd("auto");
return;
}
this.heldTop = target;
if (Math.abs(this.viewportTarget.scrollTop - target) >= 1) {
this.viewportTarget.scrollTop = target;
}
}
// --- Public scroll commands ---------------------------------------------
scrollToEnd(behavior = "smooth") {
if (!this.hasViewportTarget) return false;
this.clearSpacer();
this.following = true;
this.scrollTo(this.viewportTarget.scrollHeight, behavior);
return true;
}
scrollToStart(behavior = "smooth") {
if (!this.hasViewportTarget) return false;
this.clearSpacer();
this.following = false;
this.scrollTo(0, behavior);
return true;
}
// Scroll a row with a matching messageId into view. Returns false when the
// target is not mounted.
scrollToMessage(id, behavior = "smooth") {
if (!this.hasContentTarget || !this.hasViewportTarget) 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") {
this.anchored = item;
this.heldTop = this.sizeSpacer(item);
this.scrollTo(this.heldTop, behavior);
}
// Bound to the scroll button's click action; direction and behavior come
// from its action params. The button goes inactive once the scroll lands,
// so drop focus first (as shadcn does) instead of leaving it on a hidden,
// inert element.
jump(event) {
event?.currentTarget?.blur();
const { direction, behavior = "smooth" } = event?.params || {};
if (direction === "start") {
this.scrollToStart(behavior);
} else {
this.scrollToEnd(behavior);
}
}
// --- Internals -----------------------------------------------------------
// Where an anchored row should land: its top minus the peek of the
// previous row and the scroll margin.
anchorTop(item) {
if (!this.hasViewportTarget) return 0;
return Math.max(0, this.scrollOffset(item) - this.previousItemPeekValue - this.scrollMarginValue);
}
// An element's top in the viewport's scroll coordinates.
scrollOffset(element) {
const viewport = this.viewportTarget;
return element.getBoundingClientRect().top - viewport.getBoundingClientRect().top + viewport.scrollTop;
}
// Grows the spacer just enough for the anchored row to reach its target
// even when there isn't enough content below it (hides it when there is),
// and returns the target.
sizeSpacer(item) {
const target = this.anchorTop(item);
if (!this.hasSpacerTarget || !this.hasViewportTarget || !this.hasContentTarget) return target;
const spacer = this.spacerTarget;
// The rows' bottom must reach this for the anchor to sit at its target.
const needed = target + this.viewportTarget.clientHeight;
const rows = [...this.contentTarget.children].filter((el) => el !== spacer);
const padding = parseFloat(getComputedStyle(this.contentTarget).paddingBottom) || 0;
// Walk down from the anchor, rendering each row so it measures for real
// (content-visibility: auto keeps off-screen rows at an estimated height),
// and stop once the rows already fill the viewport: no spacer needed, and
// a long transcript below the anchor is never rendered all at once.
// scrollHeight won't do: min-h-full stretches a short transcript.
let end = 0;
for (let i = Math.max(0, rows.indexOf(item)); i < rows.length; i++) {
const row = rows[i];
this.renderRows([row]);
end = this.scrollOffset(row) + row.offsetHeight + (i === rows.length - 1 ? padding : 0);
if (end >= needed) break;
}
const missing = needed - end;
if (missing > 0) {
// The flex gap before the spacer counts toward the room it adds; when
// the room needed is smaller than the gap, pull the spacer back over it.
const gap = this.rowGap();
spacer.hidden = false;
spacer.style.height = `${Math.max(0, missing - gap)}px`;
spacer.style.marginTop = missing < gap ? `${missing - gap}px` : "";
} else {
spacer.hidden = true;
spacer.style.height = "";
spacer.style.marginTop = "";
}
return target;
}
// Keeps rows rendered until after the next paint, when
// contain-intrinsic-size: auto has recorded their real size; until then
// the browser would fall back to the estimate.
renderRows(rows) {
this.forcedRows ??= new Set();
rows.forEach((row) => {
row.style.contentVisibility = "visible";
this.forcedRows.add(row);
});
if (this.releaseFrame) return;
this.releaseFrame = requestAnimationFrame(() => {
this.releaseFrame = requestAnimationFrame(() => {
this.releaseFrame = null;
this.forcedRows.forEach((row) => (row.style.contentVisibility = ""));
this.forcedRows.clear();
});
});
}
clearSpacer() {
this.anchored = null;
this.heldTop = null;
if (!this.hasSpacerTarget) return;
this.spacerTarget.hidden = true;
this.spacerTarget.style.height = "";
this.spacerTarget.style.marginTop = "";
}
rowGap() {
if (!this.hasContentTarget) return 0;
const value = parseFloat(getComputedStyle(this.contentTarget).rowGap);
return Number.isFinite(value) ? value : 0;
}
// Native smooth scrolling (Element.scrollTo), done on scrollend. Browsers
// without scrollend get a timeout instead.
scrollTo(top, behavior = "smooth") {
if (!this.hasViewportTarget) return;
const viewport = this.viewportTarget;
// Stimulus actions pass the event as the first argument.
if (behavior !== "auto" && behavior !== "smooth" && behavior !== "instant") behavior = "smooth";
if (this.prefersReducedMotion()) behavior = "auto";
const max = viewport.scrollHeight - viewport.clientHeight;
const target = Math.max(0, Math.min(top, max));
this.cancelPendingScroll?.();
if (Math.abs(viewport.scrollTop - target) < 1) {
this.finishScroll();
return;
}
this.programmatic = true;
this.element.setAttribute("data-autoscrolling", "");
viewport.setAttribute("data-autoscrolling", "");
if (behavior !== "smooth") {
viewport.scrollTop = target;
this.finishScroll();
return;
}
const done = () => {
this.cancelPendingScroll();
this.finishScroll();
};
// Without scrollend, call it done once scroll events stop arriving.
const native = "onscrollend" in viewport;
let timer = null;
const idle = () => {
clearTimeout(timer);
timer = setTimeout(done, 150);
};
viewport.addEventListener(native ? "scrollend" : "scroll", native ? done : idle);
if (!native) idle();
this.cancelPendingScroll = () => {
clearTimeout(timer);
viewport.removeEventListener(native ? "scrollend" : "scroll", native ? done : idle);
this.cancelPendingScroll = null;
};
viewport.scrollTo({ top: target, behavior: "smooth" });
}
finishScroll() {
// Rows measured during the scroll may have settled to their real height:
// land the held turn on its target.
this.hold();
this.programmatic = false;
this.element.removeAttribute("data-autoscrolling");
if (this.hasViewportTarget) this.viewportTarget.removeAttribute("data-autoscrolling");
// A turn settled while following stays followed even when its reply runs
// past the bottom: the reader hasn't moved, so the next turn is theirs
// too. Leaving the held turn (onScroll, release) ends it.
if (!(this.anchored && this.following)) this.following = this.isAtEnd();
this.updateState();
}
// Drops a programmatic scroll in flight without landing it.
abortScroll() {
if (this.cancelPendingScroll && this.hasViewportTarget) {
// Halt the native smooth scroll where it is; the reader's own input
// takes over from there.
const viewport = this.viewportTarget;
viewport.scrollTo({ top: viewport.scrollTop, behavior: "instant" });
}
this.cancelPendingScroll?.();
this.programmatic = false;
this.element.removeAttribute("data-autoscrolling");
if (this.hasViewportTarget) this.viewportTarget.removeAttribute("data-autoscrolling");
}
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="true"]')
: [];
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 && this.anchorTop(last) > 0) {
this.following = false;
this.anchored = last;
this.heldTop = this.sizeSpacer(last);
this.viewportTarget.scrollTop = this.heldTop;
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.scrollEdgeThresholdValue;
}
isAtStart() {
if (!this.hasViewportTarget) return true;
return this.viewportTarget.scrollTop <= this.scrollEdgeThresholdValue;
}
// data-scrollable on the root and viewport, and each button's active state:
// an end button when the reader is away from the bottom, a start button
// when away from the top.
updateState() {
if (!this.hasViewportTarget) return;
const toStart = !this.isAtStart();
const toEnd = !this.isAtEnd();
const scrollable = [toStart && "start", toEnd && "end"].filter(Boolean).join(" ");
const root = this.viewportTarget.closest('[data-slot="message-scroller"]');
for (const el of [root, this.viewportTarget]) {
if (!el) continue;
if (scrollable) el.setAttribute("data-scrollable", scrollable);
else el.removeAttribute("data-scrollable");
}
this.buttonTargets.forEach((button) => {
const active = button.dataset.direction === "start" ? toStart : toEnd;
button.setAttribute("data-active", active ? "true" : "false");
// Hidden buttons leave the tab order and the accessibility tree.
button.setAttribute("tabindex", active ? "0" : "-1");
button.inert = !active;
});
}
}
9
Update the Stimulus controllers manifest file
rake stimulus:manifest:update10
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 |