Message Scroller

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.

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

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

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
Kickoff Scope change Latest Kickoff 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.

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.

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 messages The 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.

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

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.

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.

CSS parts

PartDescription::part(message-scroller-item)A message row.

<tp-message-scroller-viewport>

Attributes

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.

CSS parts

PartDescription::part(message-scroller-content)The transcript log.

<tp-message-scroller-return-control>

Attributes

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.