Skip to content

Scroll rail

<aparte-scroll-rail>

A rail of ticks beside the transcript — one per turn — that shows where you are in a long conversation and jumps back to any message on a click.

The thing every long chat is missing and no product ships natively: the scrollbar says how far down you are, not which question you are under. This is the minimap of a code editor reduced to its ticks, the search marks of a browser’s scrollbar, the scrollspy of a documentation site — the same ancestry, applied to turns. A tick per USER message by default, because a user’s turns are the questions and a rail with a tick per reply is unreadable on a long thread; every="message" marks each one.

It owns nothing of the transcript: the viewport keeps its scroll, the bubbles keep their markup, and the rail only READS them — which bubbles exist (a mutation observer on the chat), which one is under the reader (an intersection observer on the scroll surface, never scroll arithmetic), and the first few words of each for the tick’s name. A click is a scrollIntoView on the bubble, announced first by a cancelable aparte-scroll-rail-jump so a host that pages history in can load it before the jump. Honoured by core alone, so it is live by default (ratified decision #8, tier a).

Place it as a direct child of <aparte-chat> (or the wrapper’s [data-aparte-chat] host): the stylesheet floats it on the transcript’s end edge, centred on the transcript and clear of a classic scrollbar, and hides it under a coarse pointer, where a 2px tick is not a target. Under two ticks it renders nothing — a rail with one mark says nothing. It is a list, not a minimap: it takes the height of its ticks, up to a share of the transcript (--aparte-scroll-rail-share, 60%). When more turns exist than 24px targets fit in that, the pitch tightens to what fits, down to a 6px floor; past that the rail scrolls itself so the current tick is always in view, and the arrow keys walk every tick whatever the pitch.

Width Open in a tab
The real element, in its own document so this site's CSS cannot reach it. It renders the same example printed below — every framework mounts this same element, so the rendering is what you get in all five; the code is what changes.
<aparte-chat style="height: 18rem">
<aparte-chat-viewport>
<aparte-chat-bubble message-id="u1" data-role="user" content="What is a web component?"></aparte-chat-bubble>
<aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant" content="A custom element: a tag the browser upgrades to a class you wrote. It carries its own markup, behaviour and, if you want, its own styles — and it works in any framework, or none, because it is the platform's own component model."></aparte-chat-bubble>
<aparte-chat-bubble message-id="u2" data-role="user" content="And a light-DOM one?"></aparte-chat-bubble>
<aparte-chat-bubble message-id="a2" data-role="assistant" name="Assistant" content="One that renders its children into the page rather than into a shadow root, so the page's CSS reaches inside it. aparté is light DOM on purpose: a theme is a stylesheet, not an API."></aparte-chat-bubble>
<aparte-chat-bubble message-id="u3" data-role="user" content="Which one is this rail?"></aparte-chat-bubble>
<aparte-chat-bubble message-id="a3" data-role="assistant" name="Assistant" content="Light DOM, like everything else here. Its ticks are plain buttons in a list; scroll the transcript and watch the current one move."></aparte-chat-bubble>
</aparte-chat-viewport>
<aparte-scroll-rail></aparte-scroll-rail>
<aparte-composer>
<div class="aparte-composer-shell">
<div class="aparte-composer-row">
<aparte-composer-input></aparte-composer-input>
<aparte-composer-send></aparte-composer-send>
</div>
</div>
</aparte-composer>
</aparte-chat>
AttributeDescription
targetThe id of the <aparte-chat> to follow, when the element is not inside it.
everyuser (default): one tick per user turn. message: one per message.
data-emptyReflected BY the element while it has fewer than two ticks. Read-only.
PropertyTypeDescription
currentMessageId (readonly)string | nullThe id of the message the rail currently marks, or null.
MethodDescription
jumpTo(messageId: string): voidScroll the transcript to a message. Announced first, cancelable; then a scrollIntoView on the bubble, smooth unless the reader prefers reduced motion. The clicked tick is the mark until the transcript has stopped moving: while a smooth scroll crosses other questions the band would name each in turn, and it ended on the wrong one two times in three before this hold. Where the scroll lands is checked once it has settled, and corrected. The bubbles carry content-visibility: auto, so a bubble far from the reader has an estimated height until it is rendered: the scroll aims at an estimate, the bubbles above it take their real size on the way, and the message ends up above or below the top — measured from 36px to 1,213px off, in all three engines, the further the worse. A second scrollIntoView from a rendered neighbourhood lands; a few are allowed.
EventTypeDescription
aparte-scroll-rail-jumpCustomEvent&lt;AparteScrollRailJumpDetail>A tick was activated. Bubbles, cancelable: preventDefault() leaves the transcript where it is.

Override any of these on :root, on a subtree, or on one instance — custom properties inherit downward. Some are this element’s own; others are site-wide tokens that also style it, and overriding one of those at :root moves everything that reads it. The full set is in the CSS variables reference.

VariableDefaultDescription
--aparte-scroll-rail-widthmax(var(--aparte-scroll-rail-hit-size), calc(var(--aparte-scroll-rail-tick-size) * 1.6))The rail’s column, the ticks end-aligned in it. Never under one tick’s pressable zone: the rail clips, so a narrower column would cut the target back.
--aparte-scroll-rail-tick-size14pxLength of a tick; the current one is 1.6× that.
--aparte-scroll-rail-tick-thickness2pxThickness of a tick.
--aparte-scroll-rail-hit-size24pxThe pressable zone around a tick (WCAG 2.5.8). The drawn line keeps its own size; this one sizes the pseudo-element and, through the gap below, the pitch — so raising it spaces the ticks out rather than overlapping them. When the ticks would not fit, the rail sets it on itself to the pitch that does, never under 6px.
--aparte-scroll-rail-gapcalc(var(--aparte-scroll-rail-hit-size) - var(--aparte-scroll-rail-tick-thickness))Space between ticks: the zone minus the line, so gap + thickness is exactly the pitch. Set it smaller and the zones overlap.
--aparte-scroll-rail-share.6The share of the transcript’s height the rail may take. It is the height of its list up to this, centred on the transcript; past it the pitch tightens, then the rail scrolls.
--aparte-scroll-rail-bar0pxPublished BY the element: the width of the transcript’s scrollbar when it is a classic one (0 for an overlay bar), which the stylesheet adds to the rail’s end inset so the ticks never sit on the bar.
--aparte-scroll-rail-block-start0pxPublished BY the element: the distance from the host’s top edge to the transcript’s, so the rail centres on the transcript rather than on the composer too.
--aparte-scroll-rail-block-end0pxPublished BY the element: the distance from the transcript’s bottom edge to the host’s.

The element is the same object everywhere — the tag does not change. What changes is how an attribute is written and how an event reaches you.

<aparte-scroll-rail data-empty=""></aparte-scroll-rail>
el.addEventListener('aparte-scroll-rail-jump', (e) => use(e.detail));

Installation and the framework-specific traps: React · Vue · Svelte · Angular.