A native transcript viewport that opens at the latest message, follows streaming replies and jumps back to the end on request.
<tp-message-scroller>
Message Scroller
Default density
Compact density
Restart from the initial state
Show the copyable source
Why was I charged twice in September?The first charge on September 1 is your Team plan renewal. The second, on September 14, is a prorated charge for the three seats added that day.Can those seats be billed on the renewal date instead?Yes. Turn on consolidated billing under Settings → Billing, and seats added mid-cycle are charged together on your next renewal. It takes effect from the next seat change.Done. Will the September invoice change?No, issued invoices stay as they are. You'll see one combined line on the October 1 invoice from now on.
Why was I charged twice in September?The first charge on September 1 is your Team plan renewal. The second, on September 14, is a prorated charge for the three seats added that day.Can those seats be billed on the renewal date instead?Yes. Turn on consolidated billing under Settings → Billing, and seats added mid-cycle are charged together on your next renewal. It takes effect from the next seat change.Done. Will the September invoice change?No, issued invoices stay as they are. You'll see one combined line on the October 1 invoice from now on.
<tp-message-scroller label="Support conversation" style="border:var(--tp-border-width) solid var(--tp-border);border-radius:var(--tp-radius-xl);--tp-message-scroller-height:22rem;max-inline-size:28rem;margin-inline:auto">
<tp-message-scroller-viewport>
<tp-message-scroller-content>
<tp-message-scroller-item message-id="q-1" scroll-anchor>
<tp-message align="end" aria-label="You">
<tp-bubble align="end" variant="default">Why was I charged twice in September?</tp-bubble>
</tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="a-1">
<tp-message aria-label="Support">
<tp-bubble variant="ghost">The first charge on September 1 is your Team plan renewal. The second, on September 14, is a prorated charge for the three seats added that day.</tp-bubble>
</tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="q-2" scroll-anchor>
<tp-message align="end" aria-label="You">
<tp-bubble align="end" variant="default">Can those seats be billed on the renewal date instead?</tp-bubble>
</tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="a-2">
<tp-message aria-label="Support">
<tp-bubble variant="ghost">Yes. Turn on consolidated billing under Settings → Billing, and seats added mid-cycle are charged together on your next renewal. It takes effect from the next seat change.</tp-bubble>
</tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="q-3" scroll-anchor>
<tp-message align="end" aria-label="You">
<tp-bubble align="end" variant="default">Done. Will the September invoice change?</tp-bubble>
</tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="a-3">
<tp-message aria-label="Support">
<tp-bubble variant="ghost">No, issued invoices stay as they are. You'll see one combined line on the October 1 invoice from now on.</tp-bubble>
</tp-message>
</tp-message-scroller-item>
</tp-message-scroller-content>
</tp-message-scroller-viewport>
<tp-message-scroller-return-control></tp-message-scroller-return-control>
</tp-message-scroller>
Usage
The scroller owns only scrolling: it opens at the latest message, follows growth while the reader is at the end, and keeps their place when they scroll away. Your application owns the messages and transport; Message and Bubble supply the rows.
Explicit composition is one Viewport containing one Content of Items, plus an optional Return control. Scroll up in the example above and the Return control appears to bring the reader back to the end.
Native wrappers around rows are allowed. Nested scrollers discover their nearest Root and remain independent.
Do not add a second scroll container around Content.
Streaming replies
While the reader is pinned to the end, follow keeps the viewport on the growing reply. A turn marked scroll-anchor holds its prompt at the reading line, so the question stays in view while the answer fills in below it. Update streamed text in place and keep the Item’s message-id.
Boolean attributes express true by presence: follow="false" is still true. Assign properties to disable defaults (root.follow = false, root.defaultPinned = false, root.preserveOnPrepend = false).
A single new anchored turn holds its reading line while its reply consumes reserved space, then hands off to following. A batch of anchors while following retains the live end. Deliberate native scrolling releases automatic control.
Streaming
Default density
Compact density
Restart from the initial state
Show the copyable source
Hi Dana, ask me anything about your workspace.Ask a question
Hi Dana, ask me anything about your workspace.Ask a question
<div style="display:grid;gap:var(--tp-space-3);max-inline-size:28rem;margin-inline:auto;inline-size:100%">
<tp-message-scroller id="message-scroller-stream" label="Assistant conversation" style="border:var(--tp-border-width) solid var(--tp-border);border-radius:var(--tp-radius-xl);--tp-message-scroller-height:18rem">
<tp-message-scroller-item message-id="intro">
<tp-message aria-label="Assistant">
<tp-bubble variant="ghost">Hi Dana, ask me anything about your workspace.</tp-bubble>
</tp-message>
</tp-message-scroller-item>
</tp-message-scroller>
<tp-button id="message-scroller-ask" variant="outline">Ask a question</tp-button>
</div>
<script type="module">
const scroller = document.getElementById('message-scroller-stream');
const ask = document.getElementById('message-scroller-ask');
const turns = [
['How many seats are free?', 'You have 2 of 20 seats free. Two guests have not signed in for 90 days; removing them would free two more without changing your plan.'],
['Who can approve invoices?', 'Owners and members with the Billing admin role. Right now that is you and Marcus Chen. Add the role from Members → Roles.'],
['When does the plan renew?', 'Your Team plan renews on November 1. The estimated total is $480 for 20 seats, billed to the card ending in 4242.'],
];
let count = 0;
const row = (id, align, text, anchor) => {
const item = document.createElement('tp-message-scroller-item');
item.messageId = id;
item.scrollAnchor = anchor;
const message = document.createElement('tp-message');
message.align = align;
message.setAttribute('aria-label', align === 'end' ? 'You' : 'Assistant');
const bubble = document.createElement('tp-bubble');
bubble.align = align;
bubble.variant = align === 'end' ? 'default' : 'ghost';
bubble.textContent = text;
message.append(bubble);
item.append(message);
scroller.append(item);
return bubble;
};
ask.addEventListener('click', () => {
const [question, answer] = turns[count % turns.length];
count += 1;
ask.disabled = true;
row('prompt-' + count, 'end', question, true);
const reply = row('reply-' + count, 'start', '', false);
const words = answer.split(' ');
let index = 0;
const timer = setInterval(() => {
index += 1;
reply.textContent = words.slice(0, index).join(' ');
if (index >= words.length) {
clearInterval(timer);
ask.disabled = false;
}
}, 60);
});
</script>
This example uses the shorthand: Items placed directly in the Root get a generated Viewport, Content and Return control.
Use explicit composition to omit the control or configure constituent APIs. Root’s returnDirection configures only its implicit control. Do not mix an explicit viewport with direct Root rows.
Defaults are role="log", aria-live="polite", aria-relevant="additions", aria-atomic="false". Override log semantics through the message-scroller-content part contract when the surrounding application owns announcements:
content.partContracts = {
'message-scroller-content': {
hostProperties: { role: 'list', 'aria-live': 'off', 'aria-busy': 'false' },
},
};
// If using role=list, give each Item's semantic part role=listitem as well.
During a reply, set aria-busy through this contract and clear it when the reply ends. Avoid a second live region repeating the entire transcript or announcing every token. Viewport remains the named, focusable region even when Content’s semantics change.
tp-value-change bubbles and is composed/cancelable. Its Boolean detail.value proposes pinned state; previousValue, reason, sourceEvent, optional trigger, cancelled and allowPropagation follow the shared state contract. Keyboard intent reports keyboard, pointer/wheel/touch intent reports pointer, and commands or detected scroll changes report programmatic. There is no additional Message Scroller commit event.
scroller.pinned = true; // initialize before connection for controlled ownership
scroller.addEventListener('tp-value-change', (event) => {
if (event.target !== scroller) return;
// Accept synchronously. Omit this assignment or preventDefault() to reject.
scroller.pinned = event.detail.value;
});
Rejected proposals retain the accepted state/mode and restore its position. In uncontrolled mode, optional cancellation works through the same event. Do not switch an already mounted uncontrolled instance to controlled ownership.
Turns in a group chat
scroll-anchor marks where a turn starts, independent of who wrote it. In a group chat, a Marker announcing a new participant can start the turn, and their first message lands below it. previous-item-peek keeps that many pixels of the preceding message visible above each new turn, so the reader keeps some context.
Join marker as anchor
Default density
Compact density
Restart from the initial state
Show the copyable source
The pricing page is live on staging. Can someone check the annual toggle?Checked: annual prices show the 20% discount, but the badge overlaps the plan name on small screens. Screenshot is in the ticket.Thanks. Let's bring in Grace from design before we fix it.Add Grace to the channel
The pricing page is live on staging. Can someone check the annual toggle?Checked: annual prices show the 20% discount, but the badge overlaps the plan name on small screens. Screenshot is in the ticket.Thanks. Let's bring in Grace from design before we fix it.Add Grace to the channel
<div style="display:grid;gap:var(--tp-space-3);max-inline-size:28rem;margin-inline:auto;inline-size:100%">
<tp-message-scroller id="message-scroller-group" label="Launch channel" previous-item-peek="64" style="border:var(--tp-border-width) solid var(--tp-border);border-radius:var(--tp-radius-xl);--tp-message-scroller-height:18rem">
<tp-message-scroller-item message-id="lena-1">
<tp-message author="Lena Ortiz"><tp-bubble>The pricing page is live on staging. Can someone check the annual toggle?</tp-bubble></tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="omar-1">
<tp-message author="Omar Haddad"><tp-bubble>Checked: annual prices show the 20% discount, but the badge overlaps the plan name on small screens. Screenshot is in the ticket.</tp-bubble></tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="lena-2">
<tp-message author="Lena Ortiz"><tp-bubble>Thanks. Let's bring in Grace from design before we fix it.</tp-bubble></tp-message>
</tp-message-scroller-item>
</tp-message-scroller>
<tp-button id="message-scroller-group-add" variant="outline" style="justify-self:start">Add Grace to the channel</tp-button>
</div>
<script type="module">
const scroller = document.getElementById('message-scroller-group');
const add = document.getElementById('message-scroller-group-add');
const item = (id, anchor, content) => {
const row = document.createElement('tp-message-scroller-item');
row.messageId = id;
row.scrollAnchor = anchor;
row.append(content);
scroller.append(row);
};
add.addEventListener('click', () => {
add.disabled = true;
const marker = document.createElement('tp-marker');
marker.variant = 'separator';
marker.textContent = 'Grace Kim joined the channel';
item('grace-joined', true, marker);
setTimeout(() => {
if (!scroller.isConnected) return;
const message = document.createElement('tp-message');
message.author = 'Grace Kim';
const bubble = document.createElement('tp-bubble');
bubble.textContent = 'Hi all! I will move the badge above the plan name and send an updated mock within the hour.';
message.append(bubble);
item('grace-1', false, message);
}, 900);
});
</script>
Jump to a message
scrollToMessage, scrollToStart and scrollToEnd return a receipt whose finished promise settles as completed, superseded or rejected. Use them for an outline, search results or a “jump to unread” control.
This is not built-in virtualization: application virtualizers own their rendered window and spacers. Supply knownMessageIds before commanding an unmounted target, mount the requested row, then await the command receipt.
Known unmounted targets and commands awaiting a measurable viewport return pending. Unknown IDs and rejected controlled proposals return rejected. Pending is not a loading operation: the application must mount its known row.
Commands
Default density
Compact density
Restart from the initial state
Show the copyable source
KickoffScope changeLatestKickoff notes are in the project doc. Launch target is October 20.Design review passed. Two follow-ups on the empty states.Scope change: CSV import moves to the next release so we keep the date.QA found three blockers, all assigned. Retest is scheduled for Monday.All blockers closed. We are ready for the launch checklist.
KickoffScope changeLatestKickoff notes are in the project doc. Launch target is October 20.Design review passed. Two follow-ups on the empty states.Scope change: CSV import moves to the next release so we keep the date.QA found three blockers, all assigned. Retest is scheduled for Monday.All blockers closed. We are ready for the launch checklist.
<div style="display:grid;gap:var(--tp-space-3);max-inline-size:28rem;margin-inline:auto;inline-size:100%">
<tp-button-group id="message-scroller-jumps" label="Jump to" style="justify-self:start">
<tp-button size="sm" variant="outline" data-target="kickoff">Kickoff</tp-button>
<tp-button size="sm" variant="outline" data-target="scope">Scope change</tp-button>
<tp-button size="sm" variant="outline" data-target="end">Latest</tp-button>
</tp-button-group>
<tp-message-scroller id="message-scroller-commands" label="Project thread" style="border:var(--tp-border-width) solid var(--tp-border);border-radius:var(--tp-radius-xl);--tp-message-scroller-height:14rem">
<tp-message-scroller-item message-id="kickoff">
<tp-message author="Lena Ortiz" timestamp="2026-09-02T09:15:00"><tp-bubble>Kickoff notes are in the project doc. Launch target is October 20.</tp-bubble></tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="design">
<tp-message author="Omar Haddad" timestamp="2026-09-09T16:40:00"><tp-bubble>Design review passed. Two follow-ups on the empty states.</tp-bubble></tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="scope">
<tp-message author="Lena Ortiz" timestamp="2026-09-18T11:05:00"><tp-bubble>Scope change: CSV import moves to the next release so we keep the date.</tp-bubble></tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="qa">
<tp-message author="Grace Kim" timestamp="2026-09-30T14:20:00"><tp-bubble>QA found three blockers, all assigned. Retest is scheduled for Monday.</tp-bubble></tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="ready">
<tp-message author="Omar Haddad" timestamp="2026-10-08T10:00:00"><tp-bubble>All blockers closed. We are ready for the launch checklist.</tp-bubble></tp-message>
</tp-message-scroller-item>
</tp-message-scroller>
</div>
<script type="module">
const scroller = document.getElementById('message-scroller-commands');
document.getElementById('message-scroller-jumps').addEventListener('click', (event) => {
const target = event.target.closest('[data-target]')?.dataset.target;
if (!target) return;
if (target === 'end') scroller.scrollToEnd({ behavior: 'smooth' });
else scroller.scrollToMessage(target, { align: 'start', behavior: 'smooth' });
});
</script>
Commands preserve focus, so the reader stays on the button they pressed. A message command releases following; a completed end command resumes it.
Reader position
provider.subscribeVisibility reports the visible message ids and currentAnchorId, the last anchored turn that reached the reading line, which drives an outline. provider.scrollable holds independent start and end flags for content beyond each edge; subscribe with true to receive the current value too. Here reading-line="16" places the reading line just below the top, and the outline’s commands pass scrollMargin: 0 so a turn they jump to lands above it and becomes current.
The peek band is excluded from reading visibility. Subscribing does not synchronously call the listener; read provider.visibility if an immediate snapshot is needed.
Outline and edges
Default density
Compact density
Restart from the initial state
Show the copyable source
Can we require single sign-on?Yes. On the Business plan, turn on Enforce SSO under Security. Members without an identity-provider account are signed out at their next session refresh, so invite them in your provider first. Guests can keep email sign-in if you allow it.How do I export all projects?Owners can start a full export from Settings → Data. You get an email with a download link when the archive is ready, usually within an hour. The archive holds every project as JSON plus all attachments, and the link stays valid for seven days.What happens when we run out of seats?New invites wait as pending until a seat frees up or an owner adds one. Adding a seat mid-cycle is prorated on your next invoice. Nobody already in the workspace loses access.
Can we require single sign-on?Yes. On the Business plan, turn on Enforce SSO under Security. Members without an identity-provider account are signed out at their next session refresh, so invite them in your provider first. Guests can keep email sign-in if you allow it.How do I export all projects?Owners can start a full export from Settings → Data. You get an email with a download link when the archive is ready, usually within an hour. The archive holds every project as JSON plus all attachments, and the link stays valid for seven days.What happens when we run out of seats?New invites wait as pending until a seat frees up or an owner adds one. Adding a seat mid-cycle is prorated on your next invoice. Nobody already in the workspace loses access.
<div style="display:grid;grid-template-columns:minmax(0,1fr) 10rem;gap:var(--tp-space-4);max-inline-size:36rem;margin-inline:auto;inline-size:100%">
<tp-message-scroller id="message-scroller-outline" label="Onboarding questions" initial-position="start" reading-line="16" style="border:var(--tp-border-width) solid var(--tp-border);border-radius:var(--tp-radius-xl);--tp-message-scroller-height:18rem">
<tp-message-scroller-item message-id="sso" scroll-anchor>
<tp-message align="end" aria-label="You"><tp-bubble align="end">Can we require single sign-on?</tp-bubble></tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="sso-reply">
<tp-message aria-label="Assistant"><tp-bubble variant="ghost">Yes. On the Business plan, turn on Enforce SSO under Security. Members without an identity-provider account are signed out at their next session refresh, so invite them in your provider first. Guests can keep email sign-in if you allow it.</tp-bubble></tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="export" scroll-anchor>
<tp-message align="end" aria-label="You"><tp-bubble align="end">How do I export all projects?</tp-bubble></tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="export-reply">
<tp-message aria-label="Assistant"><tp-bubble variant="ghost">Owners can start a full export from Settings → Data. You get an email with a download link when the archive is ready, usually within an hour. The archive holds every project as JSON plus all attachments, and the link stays valid for seven days.</tp-bubble></tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="seats" scroll-anchor>
<tp-message align="end" aria-label="You"><tp-bubble align="end">What happens when we run out of seats?</tp-bubble></tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="seats-reply">
<tp-message aria-label="Assistant"><tp-bubble variant="ghost">New invites wait as pending until a seat frees up or an owner adds one. Adding a seat mid-cycle is prorated on your next invoice. Nobody already in the workspace loses access.</tp-bubble></tp-message>
</tp-message-scroller-item>
</tp-message-scroller>
<nav aria-label="Questions" style="display:grid;gap:var(--tp-space-1);align-content:start">
<div id="message-scroller-outline-nav" style="display:grid;gap:var(--tp-space-1)">
<tp-button size="sm" variant="ghost" data-target="sso">Single sign-on</tp-button>
<tp-button size="sm" variant="ghost" data-target="export">Export</tp-button>
<tp-button size="sm" variant="ghost" data-target="seats">Seats</tp-button>
</div>
<tp-separator></tp-separator>
<tp-button id="message-scroller-outline-first" size="sm" variant="outline">First</tp-button>
<tp-button id="message-scroller-outline-latest" size="sm" variant="outline">Latest</tp-button>
</nav>
</div>
<script type="module">
await customElements.whenDefined('tp-message-scroller');
const scroller = document.getElementById('message-scroller-outline');
const nav = document.getElementById('message-scroller-outline-nav');
const first = document.getElementById('message-scroller-outline-first');
const latest = document.getElementById('message-scroller-outline-latest');
nav.addEventListener('click', (event) => {
const target = event.target.closest('[data-target]')?.dataset.target;
if (target) scroller.scrollToMessage(target, { align: 'start', behavior: 'smooth', scrollMargin: 0 });
});
first.addEventListener('click', () => scroller.scrollToStart({ behavior: 'smooth' }));
latest.addEventListener('click', () => scroller.scrollToEnd({ behavior: 'smooth' }));
const markCurrent = ({ currentAnchorId }) => {
for (const button of nav.querySelectorAll('[data-target]')) {
button.variant = button.dataset.target === currentAnchorId ? 'secondary' : 'ghost';
}
};
scroller.provider.subscribeVisibility(markCurrent);
markCurrent(scroller.provider.visibility);
scroller.provider.scrollable.subscribe(({ value }) => {
first.disabled = !value.start;
latest.disabled = !value.end;
}, true);
</script>
Loading earlier messages
Insert older messages at the top of Content as they load. While preserve-on-prepend is on, the default, the first visible message keeps its position on screen, so the reader does not lose their place.
Prepend history
Default density
Compact density
Restart from the initial state
Show the copyable source
Load earlier messagesThe export finished overnight, thank you. One project is missing its attachments, though.That project was archived before the export started. I have restored it and queued a second export for it alone.Got it, everything is there now.
Load earlier messagesThe export finished overnight, thank you. One project is missing its attachments, though.That project was archived before the export started. I have restored it and queued a second export for it alone.Got it, everything is there now.
<div style="display:grid;gap:var(--tp-space-3);max-inline-size:28rem;margin-inline:auto;inline-size:100%">
<tp-button id="message-scroller-history-load" variant="outline" size="sm" style="justify-self:start">Load earlier messages</tp-button>
<tp-message-scroller id="message-scroller-history" label="Support ticket #5521" style="border:var(--tp-border-width) solid var(--tp-border);border-radius:var(--tp-radius-xl);--tp-message-scroller-height:16rem">
<tp-message-scroller-viewport>
<tp-message-scroller-content>
<tp-message-scroller-item message-id="t-4">
<tp-message author="Dana Wells" timestamp="2026-10-13T09:12:00"><tp-bubble>The export finished overnight, thank you. One project is missing its attachments, though.</tp-bubble></tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="t-5">
<tp-message author="Support" timestamp="2026-10-13T10:03:00"><tp-bubble variant="outline">That project was archived before the export started. I have restored it and queued a second export for it alone.</tp-bubble></tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="t-6">
<tp-message author="Dana Wells" timestamp="2026-10-13T11:40:00"><tp-bubble>Got it, everything is there now.</tp-bubble></tp-message>
</tp-message-scroller-item>
</tp-message-scroller-content>
</tp-message-scroller-viewport>
<tp-message-scroller-return-control></tp-message-scroller-return-control>
</tp-message-scroller>
</div>
<script type="module">
const content = document.querySelector('#message-scroller-history tp-message-scroller-content');
const load = document.getElementById('message-scroller-history-load');
const earlier = [
['t-1', 'Dana Wells', '2026-10-12T15:20:00', 'We need a full export of the workspace before our audit on Friday.'],
['t-2', 'Support', '2026-10-12T15:34:00', 'Happy to help. Owners can start one from Settings → Data; I have also raised the size limit for your account.'],
['t-3', 'Dana Wells', '2026-10-12T16:02:00', 'Started it. The page says it may take a few hours.'],
];
load.addEventListener('click', () => {
load.disabled = true;
const rows = earlier.map(([id, author, time, text]) => {
const row = document.createElement('tp-message-scroller-item');
row.messageId = id;
const message = document.createElement('tp-message');
message.author = author;
message.timestamp = time;
const bubble = document.createElement('tp-bubble');
if (author === 'Support') bubble.variant = 'outline';
bubble.textContent = text;
message.append(bubble);
row.append(message);
return row;
});
content.prepend(...rows);
load.textContent = 'Start of conversation';
});
</script>
Entrance animation
The scroller has no animation property; animate a new message’s inner presentation with opacity and transform after mounting it, so the Item’s height stays stable. Check resolvesReducedMotion first to honor the reader’s motion preference.
Animated send
Default density
Compact density
Restart from the initial state
Show the copyable source
Are we still on for the design review at 3?Send reply
Are we still on for the design review at 3?Send reply
<div style="display:grid;gap:var(--tp-space-3);max-inline-size:28rem;margin-inline:auto;inline-size:100%">
<tp-message-scroller id="message-scroller-animate" label="Direct messages" style="border:var(--tp-border-width) solid var(--tp-border);border-radius:var(--tp-radius-xl);--tp-message-scroller-height:16rem">
<tp-message-scroller-item message-id="m-1">
<tp-message author="Jonas Weber"><tp-bubble variant="secondary">Are we still on for the design review at 3?</tp-bubble></tp-message>
</tp-message-scroller-item>
</tp-message-scroller>
<tp-button id="message-scroller-animate-send" style="justify-self:start">Send reply</tp-button>
</div>
<script type="module">
import { resolvesReducedMotion } from '@tweakpad/ui';
const scroller = document.getElementById('message-scroller-animate');
const send = document.getElementById('message-scroller-animate-send');
const replies = ['Yes, see you there.', 'I will bring the updated empty states.', 'Running two minutes late.'];
let count = 0;
send.addEventListener('click', () => {
const row = document.createElement('tp-message-scroller-item');
row.messageId = 'reply-' + ++count;
const message = document.createElement('tp-message');
message.align = 'end';
message.setAttribute('aria-label', 'You');
const bubble = document.createElement('tp-bubble');
bubble.align = 'end';
bubble.textContent = replies[(count - 1) % replies.length];
message.append(bubble);
row.append(message);
scroller.append(row);
if (!resolvesReducedMotion(row)) {
message.animate(
[{ opacity: 0, transform: 'translateY(10px) scale(0.98)' }, { opacity: 1, transform: 'none' }],
{ duration: 260, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' },
);
}
});
</script>
Opening position
initial-position places the reader once, on the first content: end (default) for chats, start for a log read from the top, or last-anchor to open at the latest long turn. Changing it later never moves the reader.
last-anchor opens at the latest anchor when that turn exceeds the viewport; short turns or no anchors fall back to the end.
Open at the start
Default density
Compact density
Restart from the initial state
Show the copyable source
v2.4 adds audit log export and SAML group sync.v2.3 speeds up search on large workspaces by about 40%.v2.2 introduces guest seats and per-project permissions.v2.1 fixes calendar sync for recurring events.
v2.4 adds audit log export and SAML group sync.v2.3 speeds up search on large workspaces by about 40%.v2.2 introduces guest seats and per-project permissions.v2.1 fixes calendar sync for recurring events.
<tp-message-scroller label="Changelog" initial-position="start" style="border:var(--tp-border-width) solid var(--tp-border);border-radius:var(--tp-radius-xl);--tp-message-scroller-height:12rem;max-inline-size:28rem;margin-inline:auto">
<tp-message-scroller-item message-id="v-2-4">
<tp-message author="Release bot" timestamp="2026-10-07T08:00:00"><tp-bubble variant="outline">v2.4 adds audit log export and SAML group sync.</tp-bubble></tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="v-2-3">
<tp-message author="Release bot" timestamp="2026-09-23T08:00:00"><tp-bubble variant="outline">v2.3 speeds up search on large workspaces by about 40%.</tp-bubble></tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="v-2-2">
<tp-message author="Release bot" timestamp="2026-09-09T08:00:00"><tp-bubble variant="outline">v2.2 introduces guest seats and per-project permissions.</tp-bubble></tp-message>
</tp-message-scroller-item>
<tp-message-scroller-item message-id="v-2-1">
<tp-message author="Release bot" timestamp="2026-08-26T08:00:00"><tp-bubble variant="outline">v2.1 fixes calendar sync for recurring events.</tp-bubble></tp-message>
</tp-message-scroller-item>
</tp-message-scroller>
The frame defaults to 120 × --tp-spacing tall. Set --tp-message-scroller-height for another bound, or 100% inside a height-constrained parent.
The viewport recipe includes a native scroll-timeline bottom fade, which clears at the end; override mask-image through its presentation hook to remove it.
API Reference
Every element also accepts motion-policy.
Attributes
AttributePropertyTypeDefaultDescriptionpinnedpinnedboolean—Whether the reader is pinned to the end, where new content stays in view; set it before the scroller connects to control it, then publish each accepted proposal back.default-pinneddefaultPinnedbooleantrueWhether the scroller starts pinned to the end, when it owns the pinned state. Set the property to false to open free.followfollowbooleantrueKeeps the newest content in view as it grows while the reader is pinned to the end. Set the property to false to stop following.thresholdthresholdnumber8How close to the end, in pixels, still counts as at the end.initial-positioninitialPosition'start' | 'end' | 'preserve' | 'last-anchor''end'Where the transcript first opens: at the start, at the end, or at the latest anchored turn when it is taller than the viewport.reading-linereadingLinenumber0The reading offset below the top padding, in pixels, where an anchored turn and a jumped-to message land.previous-item-peekpreviousItemPeeknumber0How much of the previous message, in pixels, stays visible above an anchored turn.return-control-peekreturnControlPeeknumber0Extra space at the end, in pixels, so the return control never covers the last message.preserve-on-prependpreserveOnPrependbooleantrueKeeps the first visible message in place when earlier history is inserted above it.labellabelstring'Conversation'The accessible name of the viewport and the log, such as "Support chat".return-directionreturnDirection'start' | 'end''end'Which end the built-in return control scrolls to, for transcripts read from the top.
Properties
PropertyTypeDefaultDescriptionknownMessageIdsreadonly string[][]Ids of messages the application knows but has not mounted yet, so a jump to one waits for it instead of failing.providerread-onlyMessageScrollerProvidernew MessageScrollerProvider({ rows: () => { const items = [ ...this.querySelectorAll<TpMessageScrollerItem>('tp-message-scroller-item'), ].filter((row) => messageScrollerOwner(row) === this); if (items.length) return items.map((row) => ({ id: this.#rowId(row, row.messageId), anchor: row.scrollAnchor, element: row, addressable: !!row.messageId, })); // Preserve the original direct-message composition while new addressable rows use Item. const content = this.#part('message-scroller-content')?.member; return [...(this.#explicit && content ? content.children : this.children)] .filter( (node): node is HTMLElement => node instanceof HTMLElement && !node.localName.startsWith('tp-message-scroller-'), ) .map((element) => { return { id: this.#rowId(element, element.id), element, anchor: element.hasAttribute('scroll-anchor'), }; }); }, knownIds: () => this.knownMessageIds, pinned: () => this.pinned, pin: (value, event) => { this.#pin.set( value, event?.type === 'keydown' ? 'keyboard' : event ? 'pointer' : 'programmatic', event, ); return this.pinned === value; }, follow: () => this.follow, initialPosition: () => this.initialPosition, threshold: () => this.threshold, readingLine: () => Math.max(0, this.readingLine), previousItemPeek: () => Math.max(0, this.previousItemPeek), returnControlPeek: () => Math.max(0, this.returnControlPeek), preserveOnPrepend: () => (this.#part('message-scroller-viewport')?.member as TpMessageScrollerViewport | undefined) ?.preserveOnPrepend ?? this.preserveOnPrepend, reducedMotion: () => resolvesReducedMotion(this), changed: () => this.#publish(), })The scrolling engine: the same commands, the native viewport and subscriptions, for controls outside the scroller.
Events
EventTypeDescriptiontp-value-changeTpValueChangeEvent<boolean>Cancelable proposal of a new pinned state, as the reader reaches or leaves the end; a controlled scroller must publish the accepted pinned.
Methods
MethodReturnsDescriptionscrollToStart(options?)—Scrolls to the start of the transcript; returns a receipt whose status is accepted, pending or rejected and whose finished settles when the scroll ends.scrollToEnd(options?)—Scrolls to the end and resumes following; returns the same receipt as scrollToStart.scrollToMessage(id, options?)—Scrolls a message into view by its id, aligned to the start by default; a known message that is not mounted yet waits as pending.
Slots
SlotDescriptiondefaultThe items, or an explicit viewport and return control.
CSS parts
PartDescription::part(message-scroller)The root region that positions the return control over the viewport.
CSS custom properties
PropertyDefaultDescription--tp-message-scroller-height—The block size of the frame; use 100% inside a parent whose height is set.
<tp-message-scroller-item>
Attributes
AttributePropertyTypeDefaultDescriptionmessage-idmessageIdstring''The id that scrollToMessage and knownMessageIds refer to.scroll-anchorscrollAnchorbooleanfalseMarks the start of a turn, usually the reader's own prompt: a new anchored turn holds its place at the reading line while the reply streams below it.
Slots
SlotDescriptiondefaultThe message, such as a Message with its Bubble.
AttributePropertyTypeDefaultDescriptionlabellabelstring''The accessible name of the scrolling region; empty uses the scroller's label.preserve-on-prependpreserveOnPrependboolean | undefinedundefinedOverrides the scroller's preserve-on-prepend for this viewport; false leaves only the browser's own anchoring.
Properties
PropertyTypeDefaultDescriptionviewportElementread-onlyHTMLElement | null—The native scrolling element, for reading the scroll position or observing its size; null before the first render.
Slots
SlotDescriptiondefaultThe content.
<tp-message-scroller-content>
Attributes
AttributePropertyTypeDefaultDescriptionlabellabelstring''The accessible name of the log; empty uses the scroller's label.
Properties
PropertyTypeDefaultDescriptioncontentElementread-onlyHTMLElement | null—The element laying out the transcript; null before the first render.spacerElementread-onlyHTMLElement | null—The hidden space at the end the scroller sizes itself; read it, but do not move or resize it.
Slots
SlotDescriptiondefaultThe items and any other transcript content.
AttributePropertyTypeDefaultDescriptionreturn-directionreturnDirection'start' | 'end''end'Which end the control scrolls to.behaviorbehaviorScrollBehavior'smooth'How the return scroll moves; reduced motion always jumps.
Properties
PropertyTypeDefaultDescriptionactiveread-only——Whether there is content past the control's end to return from; the control is inert otherwise.
Slots
SlotDescriptiondefaultThe label. In an icon size it is hidden visually and still names the Button.icon-startA custom mark before the label, used when neither icon nor loadingPosition fills that side.icon-endA custom mark after the label, used when neither icon nor loadingPosition fills that side.
CSS parts
PartDescription::part(button)The native button or link: the target of focus, activation and the variant appearance.::part(button-label)The label; hidden visually in an icon size and when it has no content.::part(button-leading-mark)The mark before the label: the icon, the Spinner or the icon-start slot.::part(button-trailing-mark)The mark after the label: the icon, the Spinner or the icon-end slot.