Message Scroller
A streaming-aware transcript that keeps the latest message in view.
Installation
Included in poetry-ui — available as
poetry_message_scroller
the moment you've installed Poetry,
with no per-component step. To own the source and edit it, copy it into your app:
bin/rails g poetry:add message-scroller
Default
<%# A scrollable transcript of real Message rows - user turns anchored
(anchor: true), the jump button appears the moment you scroll up. %>
<div class="w-full max-w-md">
<%= poetry_message_scroller(id: "scroller-demo", class: "h-80 rounded-lg border") do %>
<% 6.times do |i| %>
<%= poetry_message_scroller_item(id: "demo-q#{i + 1}", anchor: true, class: "px-4 pt-4") do %>
<%= poetry_message(align: :end) do |message| %>
<%= poetry_bubble { "Check section #{i + 1} of the transcript." } %>
<% end %>
<% end %>
<%= poetry_message_scroller_item(id: "demo-a#{i + 1}", class: "px-4 #{"pb-4" if i == 5}") do %>
<%= poetry_message do |message| %>
<%= poetry_bubble(variant: :muted) { "Section #{i + 1} is ready. New rows land below while your reading position is respected." } %>
<% end %>
<% end %>
<% end %>
<% end %>
</div>
Anchored turns
<%# anchor: true marks the turn the controller settles near the TOP of
the viewport (with a peek of the previous exchange) instead of
snapping to the bottom - here each answer is the anchored turn. %>
<div class="w-full max-w-md">
<%= poetry_message_scroller(id: "scroller-anchored", class: "h-80 rounded-lg border") do %>
<%= poetry_message_scroller_item(id: "q1", class: "px-4 pt-4") do %>
<%= poetry_message(align: :end) do |message| %>
<%= poetry_bubble { "How do I export this report as a PDF?" } %>
<% end %>
<% end %>
<%= poetry_message_scroller_item(id: "a1", anchor: true, class: "px-4") do %>
<%= poetry_message do |message| %>
<%= poetry_bubble(variant: :muted) { "Open the report, choose Share, then pick Download as PDF. The file lands in your downloads folder." } %>
<% end %>
<% end %>
<%= poetry_message_scroller_item(id: "q2", class: "px-4") do %>
<%= poetry_message(align: :end) do |message| %>
<%= poetry_bubble { "Can I have it run every Monday instead?" } %>
<% end %>
<% end %>
<%= poetry_message_scroller_item(id: "a2", anchor: true, class: "px-4 pb-4") do %>
<%= poetry_message do |message| %>
<%= poetry_bubble(variant: :muted) { "Yes. In the report settings, turn on Recurring and set the cadence to weekly. We will email the PDF each Monday morning." } %>
<% end %>
<% end %>
<% end %>
</div>
Commands
<%# Buttons OUTSIDE the scroller drive it through the controller's
programmatic command surface - scrollToStart / scrollToEnd /
scrollToMessage(id), the same methods outlet callers use. The
demo-scroller-commands controller is docs plumbing that only
forwards the clicks. %>
<div data-controller="demo-scroller-commands" class="mx-auto flex w-full max-w-md flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
<%= poetry_button(variant: :outline, size: :sm, "data-action": "demo-scroller-commands#toStart") { "Top" } %>
<%= poetry_button(variant: :outline, size: :sm, "data-action": "demo-scroller-commands#toEnd") { "Latest" } %>
<%= poetry_dropdown_menu(align: :start) do |menu| %>
<% menu.with_trigger(variant: :outline, size: :sm) do %>
Jump to question <%= poetry_icon(name: :"chevron-down") %>
<% end %>
<% menu.with_group do |group| %>
<% group.with_item("data-demo-jump-id": "command-q1") { "The activation dip" } %>
<% group.with_item("data-demo-jump-id": "command-q2") { "What to compare" } %>
<% group.with_item("data-demo-jump-id": "command-q3") { "The experiment" } %>
<% group.with_item("data-demo-jump-id": "command-q4") { "The risk" } %>
<% end %>
<% end %>
</div>
<%= poetry_message_scroller(id: "scroller-commands", default_scroll_position: :start,
class: "h-96 rounded-lg border",
"data-demo-scroller-commands-target": "scroller") do %>
<% [
["command-q1", "We're seeing activation dip after workspace creation. Can you help me find the likely step?",
"The sharpest drop is between creating the workspace and inviting the first teammate. Workspace creation is healthy, but the invite step is where users pause - the product asks for collaboration before the user has enough confidence in the workspace."],
["command-q2", "What should I compare before we change the onboarding flow?",
"Compare three cohorts: users who choose a template before inviting teammates, users who start from a blank workspace, and users who skip invites but return within 24 hours. If template users invite faster, the fix is better first-run guidance rather than a louder invite prompt."],
["command-q3", "Can you turn that into an experiment?",
"Yes. Show a short checklist after workspace creation: pick a template, add one project detail, invite a teammate once the workspace has context. Measure first-invite completion, 24-hour return rate, and second-project creation."],
["command-q4", "What's the risk if we delay the invite prompt?",
"Reducing team creation for accounts that already know who they want to invite. Keep the invite action visible in the header and only change the primary empty-state guidance - confident teams keep a direct route."]
].each_with_index do |(id, question, answer), i| %>
<%= poetry_message_scroller_item(id: id, anchor: true, class: "px-4 #{"pt-4" if i.zero?}") do %>
<%= poetry_message(align: :end) do |message| %>
<%= poetry_bubble { question } %>
<% end %>
<% end %>
<%= poetry_message_scroller_item(id: "#{id}-answer", class: "px-4 #{"pb-4" if i == 3}") do %>
<%= poetry_message do |message| %>
<%= poetry_bubble(variant: :muted) { answer } %>
<% end %>
<% end %>
<% end %>
<% end %>
</div>
Group chat
<%# A group thread: join notices are separator Markers (real announced
content), senders are named via the Message header. %>
<div class="w-full max-w-md">
<%= poetry_message_scroller(id: "scroller-group", class: "h-80 rounded-lg border") do %>
<%= poetry_message_scroller_item(id: "join-priya", anchor: true, class: "px-4 pt-4") do %>
<%= poetry_marker(variant: :separator) { "Priya joined the channel" } %>
<% end %>
<%= poetry_message_scroller_item(id: "g1", class: "px-4") do %>
<%= poetry_message do |message| %>
<% message.with_header { "Priya" } %>
<%= poetry_bubble(variant: :muted) { "Morning! Did the staging deploy finish?" } %>
<% end %>
<% end %>
<%= poetry_message_scroller_item(id: "g2", class: "px-4") do %>
<%= poetry_message do |message| %>
<% message.with_header { "Marcus" } %>
<%= poetry_bubble(variant: :muted) { "Went green two minutes ago and the smoke tests are passing." } %>
<% end %>
<% end %>
<%= poetry_message_scroller_item(id: "join-lena", anchor: true, class: "px-4") do %>
<%= poetry_marker(variant: :separator) { "Lena joined the channel" } %>
<% end %>
<%= poetry_message_scroller_item(id: "g3", class: "px-4 pb-4") do %>
<%= poetry_message do |message| %>
<% message.with_header { "Lena" } %>
<%= poetry_bubble(variant: :muted) { "Perfect timing. I will start the release checklist now." } %>
<% end %>
<% end %>
<% end %>
</div>
Open at start
<%# default_scroll_position: :start opens the thread at its beginning -
the saved-conversation posture - instead of the chat default (:end). %>
<div class="w-full max-w-md">
<%= poetry_message_scroller(id: "scroller-saved", default_scroll_position: :start,
class: "h-72 rounded-lg border") do %>
<% [
"Welcome back. This thread opens right where the conversation began.",
"Last session we sketched the onboarding email sequence.",
"The first message is a welcome note sent the moment someone signs up.",
"The second nudges anyone who has not finished setting up their workspace.",
"The third shares a short getting-started walkthrough.",
"We also planned to test two subject lines against each other.",
"Whichever wins gets promoted to everyone after a week.",
"Pick up wherever feels right whenever you are ready."
].each_with_index do |text, i| %>
<%= poetry_message_scroller_item(id: "s#{i}", class: "px-4 #{"pt-4" if i.zero?} #{"pb-4" if i == 7}") do %>
<%= poetry_message do |message| %>
<%= poetry_bubble(variant: :muted) { text } %>
<% end %>
<% end %>
<% end %>
<% end %>
</div>
Scroll state
Newer messages below - scroll or jump down. Room in both directions. You're at the newest - older messages above.
<%# Zero JS here: the controller mirrors its state machine onto the
DOM - data-mode (the 4-state machine) and data-scrollable (which
edges have room) - so plain CSS can read it. The chips and the
footer are revealed purely by group-has selectors on those
attributes. Scroll, jump, and drag to watch them flip. %>
<div class="group/state mx-auto flex w-full max-w-md flex-col gap-3">
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="text-muted-foreground">mode:</span>
<span class="hidden items-center gap-1.5 rounded-full border px-2.5 py-1 font-medium group-has-[[data-mode=following-bottom]]/state:inline-flex"><span class="size-1.5 rounded-full bg-emerald-500"></span>following-bottom</span>
<span class="hidden items-center gap-1.5 rounded-full border px-2.5 py-1 font-medium group-has-[[data-mode=free-scrolling]]/state:inline-flex"><span class="size-1.5 rounded-full bg-amber-500"></span>free-scrolling</span>
<span class="hidden items-center gap-1.5 rounded-full border px-2.5 py-1 font-medium group-has-[[data-mode=anchored-to-message]]/state:inline-flex"><span class="size-1.5 rounded-full bg-sky-500"></span>anchored-to-message</span>
<span class="hidden items-center gap-1.5 rounded-full border px-2.5 py-1 font-medium group-has-[[data-mode=settling-jump]]/state:inline-flex"><span class="size-1.5 rounded-full bg-violet-500"></span>settling-jump</span>
</div>
<%= poetry_message_scroller(id: "scroller-state", default_scroll_position: :start,
class: "h-80 rounded-lg border") do %>
<% 6.times do |i| %>
<%= poetry_message_scroller_item(id: "state-q#{i + 1}", anchor: true, class: "px-4 #{"pt-4" if i.zero?}") do %>
<%= poetry_message(align: :end) do |message| %>
<%= poetry_bubble { "Check section #{i + 1} of the transcript." } %>
<% end %>
<% end %>
<%= poetry_message_scroller_item(id: "state-a#{i + 1}", class: "px-4 #{"pb-4" if i == 5}") do %>
<%= poetry_message do |message| %>
<%= poetry_bubble(variant: :muted) { "Section #{i + 1} is ready. The scroll state updates without touching the rows." } %>
<% end %>
<% end %>
<% end %>
<% end %>
<p class="text-center text-xs text-muted-foreground">
<span class="hidden group-has-[[data-scrollable=end]]/state:inline">Newer messages below - scroll or jump down.</span>
<span class="hidden group-has-[[data-scrollable='start_end']]/state:inline">Room in both directions.</span>
<span class="hidden group-has-[[data-scrollable=start]]/state:inline">You're at the newest - older messages above.</span>
</p>
</div>
Streaming
New Chat
Morning!
<%# The full chat surface, live: Send replays a scripted conversation
through the scroller the way your app's Turbo Streams would - each
turn is server-rendered ERB kept in a <template>, the user row pops
in anchored, and the assistant reply streams by UPDATING its row
text (never append-per-token). The demo-chat controller is docs
plumbing, not poetry API - in a real app these appends are Turbo
Streams targeting the content element (id "chat-stream-messages"). %>
<div data-controller="demo-chat" class="mx-auto w-full max-w-sm">
<%= poetry_card(class: "flex h-[34rem] w-full flex-col gap-0 overflow-hidden",
content_class: "flex min-h-0 flex-1 flex-col p-0",
header_class: "border-b pb-4") do |card| %>
<% card.with_title { "New Chat" } %>
<% card.with_description { "How can I help you today?" } %>
<% card.with_action do %>
<%# The reset action rides a wrapper (clicks bubble) - putting it on
the trigger would replace the tooltip's own wiring. %>
<span data-action="click->demo-chat#reset">
<%= poetry_tooltip do |tooltip| %>
<% tooltip.with_trigger(variant: :outline, size: :"icon-sm", label: "Reset conversation") do %>
<%= poetry_icon(name: :"rotate-cw") %>
<% end %>
Reset
<% end %>
</span>
<% end %>
<div data-demo-chat-target="empty" class="flex min-h-0 flex-1 items-center justify-center">
<%= poetry_empty do |empty| %>
<% empty.with_media { poetry_icon(name: :"message-circle-dashed") } %>
<% empty.with_title { "Morning!" } %>
<% empty.with_description { "What are we working on today? Press send to start the conversation." } %>
<% end %>
</div>
<div data-demo-chat-target="chat" hidden class="min-h-0 flex-1">
<%= poetry_message_scroller(id: "chat-stream", class: "h-full [&_[data-slot=message-scroller-content]]:justify-end [&_[data-slot=message-scroller-content]]:p-4 [&_[data-slot=message-scroller-content]]:pb-8") %>
</div>
<% card.with_footer do %>
<div class="flex w-full flex-col gap-2 rounded-xl border p-2">
<div data-demo-chat-target="queue" class="line-clamp-2 min-h-10 px-1 text-sm"></div>
<div class="flex items-center gap-1">
<%= poetry_dropdown_menu(align: :start, side: :top, modal: false) do |menu| %>
<% menu.with_trigger(variant: :outline, size: :"icon-sm", label: "Add files") do %>
<%= poetry_icon(name: :plus) %>
<% end %>
<% menu.with_group do |group| %>
<% group.with_item do %><%= poetry_icon(name: :paperclip) %>Add Photos & Files<% end %>
<% group.with_separator %>
<% group.with_item do %><%= poetry_icon(name: :image) %>Create Image<% end %>
<% group.with_item do %><%= poetry_icon(name: :telescope) %>Deep Research<% end %>
<% group.with_item do %><%= poetry_icon(name: :globe) %>Web Search<% end %>
<% end %>
<% end %>
<%= poetry_button(size: :"icon-sm", label: "Send message", class: "ml-auto",
"data-demo-chat-target": "send", "data-action": "demo-chat#send") do %>
<%= poetry_icon(name: :"arrow-up") %>
<% end %>
</div>
</div>
<% end %>
<% end %>
<div class="mt-4 px-0.5 text-center text-xs text-muted-foreground">
Demo is read only. Press send to send messages.
</div>
<template data-demo-chat-target="turn"
data-demo-preview="I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.">
<%= poetry_message_scroller_item(id: "turn-1-user", anchor: true,
class: "animate-in fade-in slide-in-from-bottom-2 duration-300") do %>
<%= poetry_message(align: :end) do |message| %>
<%= poetry_bubble { "I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around." } %>
<% end %>
<% end %>
<%= poetry_message_scroller_item(id: "turn-1-assistant") do %>
<%= poetry_message do |message| %>
<%= poetry_bubble(variant: :muted) do %>
<span data-demo-text="That's the classic streaming scroll problem. Wrap your rows in poetry_message_scroller - the viewport pins to the bottom as tokens arrive, so the latest text lands in place. And it only auto-scrolls while the reader is already at the bottom: scroll up to re-read and it backs off."></span>
<% end %>
<% end %>
<% end %>
</template>
<template data-demo-chat-target="turn"
data-demo-preview="Okay, but when I send a new message the view still feels jarring - like the whole conversation reloads.">
<%= poetry_message_scroller_item(id: "turn-2-user", anchor: true,
class: "animate-in fade-in slide-in-from-bottom-2 duration-300") do %>
<%= poetry_message(align: :end) do |message| %>
<%= poetry_bubble { "Okay, but when I send a new message the view still feels jarring - like the whole conversation reloads." } %>
<% end %>
<% end %>
<%= poetry_message_scroller_item(id: "turn-2-assistant") do %>
<%= poetry_message do |message| %>
<%= poetry_bubble(variant: :muted) do %>
<span data-demo-text="Turn anchoring fixes that. Pass anchor: true on the row that should settle near the top of the viewport instead of snapping to the bottom - this demo anchors each user turn. A small peek of the previous exchange stays visible above it, so context isn't lost."></span>
<% end %>
<% end %>
<% end %>
</template>
<template data-demo-chat-target="turn"
data-demo-preview="And if someone has scrolled up to re-read an older answer? I don't want to yank them back down.">
<%= poetry_message_scroller_item(id: "turn-3-user", anchor: true,
class: "animate-in fade-in slide-in-from-bottom-2 duration-300") do %>
<%= poetry_message(align: :end) do |message| %>
<%= poetry_bubble { "And if someone has scrolled up to re-read an older answer? I don't want to yank them back down." } %>
<% end %>
<% end %>
<%= poetry_message_scroller_item(id: "turn-3-assistant") do %>
<%= poetry_message do |message| %>
<%= poetry_bubble(variant: :muted) do %>
<span data-demo-text="You won't. Scrolling up is a deliberate opt-out - their place stays put while new tokens keep arriving below. When there's unseen content, the jump button appears; one tap returns to the newest message and re-engages follow-bottom. Same pattern as Slack or iMessage."></span>
<% end %>
<% end %>
<% end %>
</template>
<template data-demo-chat-target="turn"
data-demo-preview="Last one - does this work with assistive tech?">
<%= poetry_message_scroller_item(id: "turn-4-user", anchor: true,
class: "animate-in fade-in slide-in-from-bottom-2 duration-300") do %>
<%= poetry_message(align: :end) do |message| %>
<%= poetry_bubble { "Last one - does this work with assistive tech?" } %>
<% end %>
<% end %>
<%= poetry_message_scroller_item(id: "turn-4-assistant") do %>
<%= poetry_message do |message| %>
<%= poetry_bubble(variant: :muted) do %>
<span data-demo-text="Yes - the content element sets role=log with aria-relevant=additions, so screen readers announce new messages as they arrive. The jump button is a real button with an accessible label, and it leaves the tab order when you're already at the bottom."></span>
<% end %>
<% end %>
<% end %>
</template>
</div>
Without jump button
<%# jump_button: false drops the return-to-latest affordance - for
short regions where follow-bottom alone is enough. %>
<div class="w-full max-w-md">
<%= poetry_message_scroller(id: "scroller-plain", jump_button: false, class: "h-60 rounded-lg border") do %>
<%= poetry_message_scroller_item(id: "only", class: "p-4") do %>
<%= poetry_message do |message| %>
<%= poetry_bubble(variant: :muted) { "A quiet region: no jump button, just the log." } %>
<% end %>
<% end %>
<% end %>
</div>
API
Poetry::Ui::MessageScroller::Component — options are
constructor keywords (the poetry_* helper forwards them);
slots are composed inside the block. Generated from the gem's source documentation.
| Option | Type | Details | Description |
|---|---|---|---|
| auto_scroll: | Boolean | defaults to true |
Follows the newest message while the reader sits at the bottom; scrolling up releases the follow. |
| default_scroll_position: | Symbol | one of: start, end, last-anchor; defaults to :end |
Where the viewport lands on connect: the newest message (:end), the oldest (:start), or the last anchor: true row (:\"last-anchor\"). |
| id: | String | required | The transcript's stable identifier - the content element renders dom id \"<id>-messages\" for Turbo Streams to target. |
| jump_button: | Boolean | defaults to true |
Renders the floating jump-to-latest button (shown once the reader leaves the bottom). |
| preserve_scroll_on_prepend: | Boolean | defaults to true |
Keeps the reading position stable when history prepends into the content element. |
| track_visibility: | Boolean | defaults to false |
Opt-in observation of which rows are on screen - emits a visibility event as the visible set changes. |
Styling
Every part carries a stable data-slot attribute — target
[data-slot=…] from your own CSS to restyle it. State rides
data attributes on the parts below. This contract is verified against rendered DOM in CI.
| Part | Description |
|---|---|
| [data-slot=message-scroller] | The transcript root the controller drives - runtime scroll state is mirrored here |
| [data-slot=message-scroller-viewport] | The native scroll region (role=region, focusable) - the controller mirrors the same runtime attributes here |
| [data-slot=message-scroller-content] | The row container and Turbo Stream append target (stable dom id <id>-messages); role=log announces additions |
| [data-slot=message-scroller-item] | One transcript row (poetry_message_scroller_item) - the id is how anchoring and Streams find it |
| [data-slot=message-scroller-spacer] | Tail spacer faking scroll room below a short anchored turn - hidden at height 0 |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| message-scroller | data-mode | always once connected - the 4-state machine | following-bottom · free-scrolling · anchored-to-message · settling-jump |
| message-scroller | data-scrollable | overflow exists - carries which edges have room (start, end, or both as a space-separated pair) | — |
| message-scroller | data-autoscrolling | a programmatic scroll is settling - the follow-bottom release is suppressed while set | — |
| message-scroller-viewport | data-scrollable | overflow exists - the same edge tokens as the root | — |
| message-scroller-viewport | data-autoscrolling | a programmatic scroll is settling | — |
| message-scroller-item | data-message-id | always - the row's message id | — |
| message-scroller-item | data-scroll-anchor | anchor: true - the turn the controller holds at the reading line | — |
Wiring
The Stimulus surface each element carries — declared in the component, verified against rendered DOM in CI. Bare actions fire on the element's default event.
| Element | Controller | Wiring |
|---|---|---|
| root | poetry--core--message-scroller | registers · value auto_scroll · value default_scroll_position · value preserve_scroll_on_prepend · value track_visibility |
| viewport | poetry--core--message-scroller | target viewport |
| content | poetry--core--message-scroller | target content |
| spacer | poetry--core--message-scroller | target spacer |
| jump_button | poetry--core--message-scroller | scrollToEnd · target button |