Scroll rail

A column of ticks beside a long chat, one per prompt. Hover one to preview that turn, click to jump to it, and bookmark the ones you'll want again.

Loading preview…

Install

  1. 1

    Run this command in your Tailwind CSS project. It adds components/ai/scroll-rail.tsx and installs its dependencies.

    $ npx shadcn@latest add https://gradientdeck.com/ai-components/r/scroll-rail.json

Usage & API

Example

scroll-rail.tsx
import { ScrollRail } from "@/components/ai/scroll-rail"

<ScrollRail
  items={[]}
  onSelect={() => {}}
/>

Props ScrollRail

PropType & descriptionRequired
itemsRailItem[]Required
activeIdstringThe turn currently at the top of the viewport.Optional
onSelect(id: string) => voidRequired
bookmarksstring[]Optional
onToggleBookmark(id: string) => voidOptional
classNamestringOptional

Dependencies

  • React
  • Tailwind CSS
  • motion
  • lucide-react
Implementation notes

Ticks exports ScrollRail. The nav is labelled Jump to message. Each tick is a button whose accessible name is the prompt, with aria-current location when it is active. Ticks are STEP pixels apart, and STEP is 8. Pointer movement picks the index from the pointer's y divided by that step, clamped to the list. Focus opens the same card. The card closes 160ms after pointer leave or blur, and that timer is cancelled if the pointer comes back. Width of a tick grows when the pointer is near it, unless you are not hovering, in which case the active tick is 16px and the others are 8. The bookmark button reads Bookmark this message or Remove bookmark and sets aria-pressed. Bookmarks default to an empty array. The preview threads a move off a managed platform: inventory, Postgres, Redis, deploys, and the environment.

Choosing a layout

Use it on a thread long enough that prompts are landmarks: a working session, a research chat, a migration plan. The preview's first tick is the prompt about moving a Rails app off a managed platform onto one VPS, titled Start with an inventory, with an excerpt about writing down what the platform already does. Pass a title that is recognisable in two lines, because the card clamps the title to two lines and the excerpt to one. Set activeId from the turn at the top of the viewport, not from the turn being streamed, or the current tick will sit at the bottom the whole time.

Skip it for a short chat, and skip it as a replacement for the scrollbar. It does not scroll anything by itself. onSelect is yours, and the usual action is to scroll that turn into view. Skip the bookmark button by omitting onToggleBookmark. The ticks still render, and bookmarks you pass are still indigo, but the card will not offer a toggle. Do not put assistant messages in items. The component is one tick per prompt. A side conversation about one paragraph is the side thread variant of Conversation, not another rail.

The preview card opens on the wrong turn when I move the pointer quickly. What is it measuring?

The index is Math.floor of the pointer offset inside the rail, divided by 8. The rail does not hit-test the buttons during pointer move. It maps y to a slot. If the column is transformed or scaled, the slots will not match the visual ticks, because the code uses the rail's bounding rect and a fixed step. Keyboard focus does not use that math. It opens the tick that received focus, and ArrowUp, ArrowDown, Home, and End move focus to a neighbour. Escape only clears the card.

How do bookmarks differ from the active tick?

activeId is the turn at the top of the viewport, and you pass it. It sets aria-current and draws the tick in the strongest neutral colour. bookmarks is an array of ids. A bookmarked tick is indigo even when it is not active. onToggleBookmark is called with the id when the card's bookmark button is pressed. If you omit that callback, the button is not rendered. The component does not store bookmarks. The preview keeps them in state and passes them back in. Clicking a tick calls onSelect and does not toggle the bookmark.