Scroll rail
A rail of ticks beside a long conversation, one per question, that shows where you are and jumps back to any turn.
-
<aparte-scroll-rail>
On this page
<!-- A direct child of the chat. Scroll the transcript, or press a tick. A reading column
(--aparte-message-max-width) leaves the rail its own margin. -->
<aparte-chat style="height: 20rem; --aparte-message-max-width: 34rem">
<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 and behaviour, and it works in any framework, or none."></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."></aparte-chat-bubble>
<aparte-chat-bubble message-id="u3" data-role="user" content="Which one is this chat?"></aparte-chat-bubble>
<aparte-chat-bubble message-id="a3" data-role="assistant" name="Assistant" content="Light DOM, like everything in aparté: a theme is a stylesheet, not an API."></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>A scrollbar says how far down you are, not which question you are under. The rail does: one tick per question, the current one longer, and a click jumps back to it. It is the minimap of a code editor reduced to its ticks, applied to turns.
It only reads the conversation: which messages exist, which one is in view, and the first words of each to name its tick. The transcript keeps its own scrolling and its own markup. With fewer than two ticks it shows nothing.
Variants
Section titled “Variants”A tick for every message
Section titled “A tick for every message”<aparte-chat style="height: 20rem; --aparte-message-max-width: 34rem">
<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 and behaviour, and it works in any framework, or none."></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."></aparte-chat-bubble>
<aparte-chat-bubble message-id="u3" data-role="user" content="Which one is this chat?"></aparte-chat-bubble>
<aparte-chat-bubble message-id="a3" data-role="assistant" name="Assistant" content="Light DOM, like everything in aparté: a theme is a stylesheet, not an API."></aparte-chat-bubble>
</aparte-chat-viewport>
<aparte-scroll-rail every="message"></aparte-scroll-rail>
</aparte-chat>every="message" marks replies too. The default marks only questions, because a tick per reply
makes a long thread unreadable.
Place it as a direct child of <aparte-chat>: it floats along the
transcript’s edge, clear of the scrollbar. Outside the chat, target names the chat it follows.
<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.
Before a jump, it fires aparte-scroll-rail-jump, which is cancelable: an app that loads older
history in pages can fetch the turn first, then scroll.
Customize
Section titled “Customize”The rail takes the height of its ticks, up to a share of the transcript
(--aparte-scroll-rail-share, 60%). When there are more turns than fit, the ticks draw closer
together, then the rail scrolls itself so the current one stays in view. It is hidden on touch
screens, where a thin tick is no target.
<aparte-scroll-rail> · class AparteScrollRail
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
| Property | Type | Description |
|---|---|---|
currentMessageId (readonly) | string | null | The id of the message the rail currently marks, or null. |
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
| Event | Type | Description |
|---|---|---|
aparte-scroll-rail-jump | CustomEvent<AparteScrollRailJumpDetail> | A tick was activated. Bubbles, cancelable: preventDefault() leaves the transcript where it is. |
CSS variables
| 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. |
Accessibility
Section titled “Accessibility”- The rail is a navigation landmark named from the
scrollRailLabelkey (“Conversation outline”); each tick is a button named by the first words of its message. - The tick in view carries
aria-current; arrow keys walk every tick. - A tick’s target is 24px tall, wider than the drawn line, as long as the rail has room for every tick (WCAG 2.5.8); past that, the ticks draw closer together.
Related
Section titled “Related”- Conversation — the transcript the rail follows.
- Chat — where it is placed.