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.
Example
Section titled “Example”<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>Attributes
Section titled “Attributes”| Attribute | Description |
|---|---|
target | The id of the <aparte-chat> to follow, when the element is not inside it. |
every | user (default): one tick per user turn. message: one per message. |
data-empty | Reflected BY the element while it has fewer than two ticks. Read-only. |
Properties
Section titled “Properties”| Property | Type | Description |
|---|---|---|
currentMessageId (readonly) | string | null | The id of the message the rail currently marks, or null. |
Methods
Section titled “Methods”| Method | Description |
|---|---|
jumpTo(messageId: string): void | Scroll 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. |
Events
Section titled “Events”| Event | Type | Description |
|---|---|---|
aparte-scroll-rail-jump | CustomEvent<AparteScrollRailJumpDetail> | A tick was activated. Bubbles, cancelable: preventDefault() leaves the transcript where it is. |
Theming
Section titled “Theming”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.
<aparte-scroll-rail>
Section titled “<aparte-scroll-rail>”| Variable | Default | Description |
|---|---|---|
--aparte-scroll-rail-width | max(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-size | 14px | Length of a tick; the current one is 1.6× that. |
--aparte-scroll-rail-tick-thickness | 2px | Thickness of a tick. |
--aparte-scroll-rail-hit-size | 24px | The 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-gap | calc(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 | .6 | The 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-bar | 0px | Published 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-start | 0px | Published 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-end | 0px | Published BY the element: the distance from the transcript’s bottom edge to the host’s. |
In a framework
Section titled “In a framework”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));<aparte-scroll-rail data-empty=""></aparte-scroll-rail>The aparte-* tags are typed JSX intrinsics as soon as you import from @aparte/react. A presence attribute takes '', never true — React stringifies it, and data-empty={false} would render data-empty="false", which hasAttribute reads as on. Events reach you by ref, typed through the DOM.
<template> <aparte-scroll-rail data-empty="" @aparte-scroll-rail-jump="(e) => use(e.detail)" ></aparte-scroll-rail></template>Declared through Vue’s GlobalComponents, so vue-tsc checks the tag in any template. A presence attribute takes '' to set and null to remove, never false.
<aparte-scroll-rail data-empty="" on:aparte-scroll-rail-jump={(e) => use(e.detail)}></aparte-scroll-rail>Declared through SvelteHTMLElements, so svelte-check covers the attributes and the on: handlers. A presence attribute takes '', never false.
import { AparteScrollRailDirective } from '@aparte/angular';<aparte-scroll-rail [dataEmpty]="true" (scrollRailJump)="use($event)"></aparte-scroll-rail>A standalone directive whose selector IS the tag, so the real element sits in the template — @if, @for and content projection all reach it — and no CUSTOM_ELEMENTS_SCHEMA is needed.
Installation and the framework-specific traps: React · Vue · Svelte · Angular.