# Source Rail
An answer card whose numbered citations open the quoted evidence behind each sentence.
## 1. Setup
```tsx
// npm install framer-motion
```
File: components-workspace/source-rail/index.tsx · 'use client' · export default function SourceRail(props: SourceRailProps)
```tsx
import { Fragment, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
import type * as React from 'react'
import { animate, AnimatePresence, motion, useIsPresent, useMotionValue, useTransform } from 'framer-motion'
import type { MotionValue } from 'framer-motion'
import { RotateCcw } from '@/app/_icons/icons/rotate-ccw'
import { ArrowUpRight } from '@/app/_icons/icons/arrow-up-right'
import { SearchDuotone } from '@/app/_icons/icons/search-duotone'
import { SparkleDuotone } from '@/app/_icons/icons/sparkle-duotone'
import { BookOpenTextDuotone } from '@/app/_icons/icons/book-open-text-duotone'
import { CircleCheckDuotone } from '@/app/_icons/icons/circle-check-duotone'
import { CircleAlertDuotone } from '@/app/_icons/icons/circle-alert-duotone'
```
The `@/app/_icons/icons/*` imports are this kit's icon set (props: `size`, `weight`, `className`). Without it, map each import to the matching glyph of any icon set with the same props. Classes need Tailwind CSS v4 (`bg-(--var)`, `@container`, `pointer-coarse:`); the `dark:` variants follow whatever dark mode your project configures.
## 2. Constants
```tsx
'use client'
// Every number the component animates or measures with: ms unless named, sizes in px.
export type Tuning = {
cardMaxW: number
cardRadius: number
railW: number
markerPx: number
enterMs: number
enterRisePx: number
writeStartMs: number
wordMs: number
paceJitter: number
commaPauseMs: number
sentencePauseMs: number
inkMs: number
inkRisePx: number
hostWordMs: number
hostWordCap: number
checkLeadMs: number
stepGapMs: number
gapHoldMs: number
riseMs: number
risePx: number
staggerMs: number
hoverMs: number
stateMs: number
panelMs: number
paneOutMs: number
paneInMs: number
underlineDrawMs: number
linkPitch: number
linkTravelMs: number
replayDelayMs: number
pressScale: number
lightMs: number
shimmerS: number
dotFieldH: number
dotPitch: number
}
export const TUNING: Tuning = {
cardMaxW: 640,
cardRadius: 24,
railW: 276,
markerPx: 18,
enterMs: 280,
enterRisePx: 6,
writeStartMs: 450,
wordMs: 52,
paceJitter: 0.35,
commaPauseMs: 90,
sentencePauseMs: 200,
inkMs: 260,
inkRisePx: 4,
hostWordMs: 12,
hostWordCap: 48,
checkLeadMs: 450,
stepGapMs: 1400,
gapHoldMs: 1100,
riseMs: 280,
risePx: 6,
staggerMs: 40,
hoverMs: 150,
stateMs: 200,
panelMs: 280,
paneOutMs: 140,
paneInMs: 220,
underlineDrawMs: 420,
linkPitch: 5,
linkTravelMs: 450,
replayDelayMs: 200,
pressScale: 0.97,
lightMs: 600,
shimmerS: 2.6,
dotFieldH: 64,
dotPitch: 6,
}
// Must match the @min-[560px] classes: the pane and the rail sit side by side from here.
const WIDE_AT = 560
// Row top to its number's centre (p-3 plus half the h-5 line); the pane's is p-4.
const ROW_PIN_Y = 22
// The strip stops this far short of the row's pin, which sits at the row's 12px padding.
const LINK_END = 12 - 6
const PANE_PIN_Y = 26
const EASE = [0.23, 1, 0.32, 1] as const
export type Verdict = 'supports' | 'partly' | 'contradicts'
export type CitedSentence = { text: string; cites?: (string | number)[] }
export type CitedSource = {
id: string | number
title: string
domain: string
verdict?: Verdict
quote?: string
highlight?: [number, number][]
locator?: string
href?: string
}
// sentence: null opens a source no sentence cites.
export type ActiveClaim = { sentence: number | null; source: string | number }
type SourceRailProps = {
question?: string
sentences?: CitedSentence[]
sources?: CitedSource[]
active?: ActiveClaim | null
defaultActive?: ActiveClaim | null
onActiveChange?: (active: ActiveClaim | null) => void
onOpenSource?: (source: CitedSource) => void
className?: string
tuning?: Partial<Tuning>
}
const NBSP = String.fromCharCode(160)
const ELLIPSIS = String.fromCharCode(8230)
function rangeOf(text: string, phrase: string): [number, number][] {
const i = text.indexOf(phrase)
return i < 0 ? [] : [[i, i + phrase.length]]
}
const QUOTE_1 = `${ELLIPSIS}in our sessions, a time to first token under 300${NBSP}ms was rated as fast regardless of total duration${ELLIPSIS}`
const QUOTE_2 = `${ELLIPSIS}median completion for long answers held at 6.1${NBSP}seconds, while short answers finished in under two${ELLIPSIS}`
const QUOTE_3 = `${ELLIPSIS}participants called a reply quick when something appeared at once, and rarely recalled how long the whole answer took${ELLIPSIS}`
const DEMO_QUESTION = 'Do streaming answers feel faster?'
const DEMO_SENTENCES: CitedSentence[] = [
{ text: `Streaming answers feel faster because the first words arrive in under 300${NBSP}ms.`, cites: [1] },
{ text: 'The full answer often takes six seconds or more.', cites: [2] },
{ text: 'People judge speed by that first signal, not by the total time.', cites: [1, 3] },
{ text: 'Showing which tool is running also lowers perceived wait.' },
]
const DEMO_SOURCES: CitedSource[] = [
{
id: 1,
title: 'Perceived latency in streaming interfaces',
domain: 'uxlab.example',
verdict: 'supports',
quote: QUOTE_1,
highlight: rangeOf(QUOTE_1, `time to first token under 300${NBSP}ms was rated as fast`),
locator: `p.${NBSP}4 · quoted`,
href: 'https://uxlab.example/perceived-latency',
},
{
id: 2,
title: 'Response time benchmarks 2026',
domain: 'benchmarks.example',
verdict: 'partly',
quote: QUOTE_2,
highlight: rangeOf(QUOTE_2, `median completion for long answers held at 6.1${NBSP}seconds`),
locator: `Table${NBSP}2 · quoted`,
href: 'https://benchmarks.example/response-time-2026',
},
{
id: 3,
title: 'How users judge waiting',
domain: 'researchnotes.example',
verdict: 'supports',
quote: QUOTE_3,
highlight: rangeOf(QUOTE_3, 'called a reply quick when something appeared at once'),
locator: `Section${NBSP}3 · quoted`,
href: 'https://researchnotes.example/judging-waits',
},
{
id: 4,
title: 'Designing for tool use',
domain: 'agentpatterns.example',
locator: 'Page summary only',
href: 'https://agentpatterns.example/tool-use',
},
]
const DEMO_STEPS: ActiveClaim[] = [
{ sentence: 0, source: 1 },
{ sentence: 1, source: 2 },
{ sentence: 2, source: 3 },
]
const VERDICT: Record<Verdict, { word: string; role: string }> = {
supports: { word: 'Supports', role: 'success' },
partly: { word: 'Partly', role: 'attention' },
contradicts: { word: "Doesn't say this", role: 'red' },
}
const THEME_VARS = [
'[--sr-page:#EEEEF3] dark:[--sr-page:#0E0E0F]',
'[--sr-panel:#F4F4FA] dark:[--sr-panel:#1B1B1C]',
'[--sr-well:#EEEEF3] dark:[--sr-well:#0E0E0F]',
'[--sr-raised:#DFDFE3] dark:[--sr-raised:#2D2D2E]',
'[--sr-raised-hover:#CACACD] dark:[--sr-raised-hover:#373738]',
'[--sr-border:#DFDFE3] dark:[--sr-border:#2D2D2E]',
'[--sr-border-strong:#CACACD] dark:[--sr-border-strong:#373738]',
'[--sr-hairline:#DFDFE3] dark:[--sr-hairline:#2D2D2E]',
'[--sr-rim:#CACACD] dark:[--sr-rim:#F4F4FA2E]',
'[--sr-lift:#FFFFFF] dark:[--sr-lift:#2D2D2E]',
'[--sr-text:#1B1B1C] dark:[--sr-text:#F4F4FA]',
'[--sr-body:#373738] dark:[--sr-body:#CACACD]',
'[--sr-muted:#575759] dark:[--sr-muted:#9B9B9E]',
'[--sr-faint:#7B7B7D]',
'[--sr-selection:#A0A0A233] dark:[--sr-selection:#5E5E6333]',
'[--sr-success:#047857] dark:[--sr-success:#34D399]',
'[--sr-ai:#0369A1] dark:[--sr-ai:#7DD3FC]',
'[--sr-attention:#B45309] dark:[--sr-attention:#FFB574]',
'[--sr-red:#C8262C] dark:[--sr-red:#E5484D]',
'[--sr-primary:#1B1B1C] dark:[--sr-primary:#F4F4FA]',
'[--sr-primary-hover:#373738] dark:[--sr-primary-hover:#FFFFFF]',
'[--sr-on-primary:#F4F4FA] dark:[--sr-on-primary:#0E0E0F]',
'[--sr-focus:#1B1B1C99] dark:[--sr-focus:#F4F4FA99]',
'[--sr-focus-gap:#F4F4FA] dark:[--sr-focus-gap:#1B1B1C]',
'[--sr-dot:#1B1B1C] dark:[--sr-dot:#F4F4FA]',
'[--sr-elev-card:0_2px_6px_rgba(0,0,0,0.06),0_28px_56px_-16px_rgba(0,0,0,0.25)] dark:[--sr-elev-card:0_2px_6px_rgba(0,0,0,0.5),0_36px_72px_-18px_rgba(0,0,0,0.9)]',
'[--sr-elev-1:0_1px_2px_rgba(0,0,0,0.08)] dark:[--sr-elev-1:0_1px_2px_rgba(0,0,0,0.5)]',
].join(' ')
const CARD =
'@container relative w-full border border-(color:--sr-border) bg-(--sr-panel) p-4 shadow-(--sr-elev-card) min-[400px]:p-5'
const WELL = 'rounded-2xl border border-(color:--sr-border) bg-(--sr-well)'
const GROUP = 'font-mono text-[11px] leading-4 text-(color:--sr-muted)'
const META = 'shrink-0 text-[12px] leading-4 tabular-nums text-(color:--sr-muted)'
const FOCUS =
'outline-none focus-visible:ring-2 focus-visible:ring-(color:--sr-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(color:--sr-focus-gap)'
// Touch hit areas grow through a pseudo-element; the visible size never changes.
const HIT_BASE = "relative pointer-coarse:after:absolute pointer-coarse:after:content-['']"
const HIT_ICON = `${HIT_BASE} pointer-coarse:after:-inset-1.5`
const BTN = `${FOCUS} inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-full px-4 text-[12px] leading-4 font-semibold transition-[background-color,color,border-color,box-shadow] duration-150`
const BTN_GHOST = `${BTN} text-(color:--sr-muted) hover:bg-(--sr-raised) hover:text-(color:--sr-text)`
type PinState = 'rest' | 'hot' | 'lit'
const PIN =
'inline-flex shrink-0 items-center justify-center rounded-full font-sans text-[11px] leading-none font-semibold tabular-nums transition-[background-color,color,box-shadow] duration-150'
const PIN_STATE: Record<PinState, string> = {
rest: 'bg-(--sr-raised) text-(color:--sr-muted)',
hot: 'bg-(--sr-raised-hover) text-(color:--sr-text)',
lit: 'bg-(--sr-primary) text-(color:--sr-on-primary)',
}
const PIN_RING = 'shadow-[0_0_0_1px_var(--sr-panel),0_0_0_2px_var(--sr-muted)]'
type Tone = 'neutral' | 'ai' | 'attention' | 'success' | 'red'
const TONE_VAR: Record<Tone, string> = {
neutral: 'var(--sr-dot)',
ai: 'var(--sr-ai)',
attention: 'var(--sr-attention)',
success: 'var(--sr-success)',
red: 'var(--sr-red)',
}
const TONE_STRENGTH: Record<Tone, number> = { neutral: 6, ai: 14, attention: 14, success: 10, red: 12 }
const REDUCED_QUERY = '(prefers-reduced-motion: reduce)'
function subscribeReduced(onChange: () => void) {
if (typeof window === 'undefined' || !window.matchMedia) return () => {}
const mq = window.matchMedia(REDUCED_QUERY)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}
function getReduced() {
return typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(REDUCED_QUERY).matches
}
function getReducedServer() {
return false
}
const same = (a: string | number, b: string | number) => String(a) === String(b)
const keyOf = (a: ActiveClaim | null | undefined) => (a ? `${a.sentence ?? 'x'}:${a.source}` : '')
const plural = (n: number, word: string) => `${n} ${n === 1 ? word : `${word}s`}`
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
// Plain spaces only: a no-break space keeps "300 ms" together as one word.
const wordsOf = (text: string) => text.split(/[ \t\n]+/).filter(Boolean)
// Only absolute http(s) addresses become links; anything else leaves the Open action out.
function safeHref(raw: string | undefined) {
if (!raw) return undefined
try {
const url = new URL(raw)
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : undefined
} catch {
return undefined
}
}
function wobble(i: number) {
const r = Math.sin((i + 1) * 12.9898) * 43758.5453
return (r - Math.floor(r)) * 2 - 1
}
function paceOf(sentences: CitedSentence[], t: Tuning) {
const at: number[] = []
let time = 0
sentences.forEach((s) => {
wordsOf(s.text).forEach((w) => {
at.push(time)
time += t.wordMs * (1 + t.paceJitter * wobble(at.length)) * (0.82 + 0.035 * Math.min(10, w.length))
if (/[,;:]$/.test(w)) time += t.commaPauseMs
})
time += t.sentencePauseMs
})
return { at, end: (at[at.length - 1] ?? 0) + t.inkMs }
}
function useInk(on: boolean, delayMs: number, drawMs: number, fadeMs: number, reduce: boolean) {
const draw = useMotionValue(0)
const alpha = useMotionValue(on ? 1 : 0)
useEffect(() => {
if (on) {
alpha.set(1)
if (reduce) {
draw.set(1)
return
}
const a = animate(draw, 1, { duration: drawMs / 1000, ease: EASE, delay: delayMs / 1000 })
return () => a.stop()
}
const a = animate(alpha, 0, { duration: fadeMs / 1000, ease: EASE, onComplete: () => draw.set(0) })
return () => a.stop()
}, [on, delayMs, drawMs, fadeMs, reduce, draw, alpha])
return { draw, alpha }
}
function linkDots(x0: number, xm: number, x1: number, yS0: number, yR: number, pitch: number) {
const h = pitch / 2
// The pane end shifts by under half a step so the vertical run is whole steps long.
const yS = yR + Math.round((yS0 - yR) / pitch) * pitch
const run = Math.abs(yR - yS)
const len = xm - x0 + run + (x1 - xm)
const out: { x: number; y: number; s: number; len: number }[] = []
const dot = (x: number, y: number, s: number) => out.push({ x, y, s, len })
for (let x = xm - h - (run ? pitch : 0); x >= x0 + 2; x -= pitch) [yS - h, yS + h].forEach((y) => dot(x, y, x - x0))
if (run) {
const top = Math.min(yS, yR) - h
for (let y = top; y <= Math.max(yS, yR) + h + 0.1; y += pitch)
[xm - h, xm + h].forEach((x) => dot(x, y, xm - x0 + Math.abs(y - yS)))
}
for (let x = xm + h + (run ? pitch : 0); x <= x1; x += pitch) [yR - h, yR + h].forEach((y) => dot(x, y, xm - x0 + run + (x - xm)))
return out
}
/**
* @typedef {object} CitedSentence
* @property {string} text Answer sentence shown in the claim text.
* @property {(string|number)[]} [cites] Source ids attached as numbered citations; left out, the sentence is flagged as having no source.
*/
/**
* @typedef {object} CitedSource
* @property {string|number} id Stable source identifier that must be unique within the sources list.
* @property {string} title Source title shown in the rail and evidence pane.
* @property {string} domain Source domain shown below its title.
* @property {Verdict} [verdict] Labels cited evidence as supporting, partial or contradictory; left out, no verdict tag is shown.
* @property {string} [quote] Passage shown when the source backs a claim; left out, a cited source says that no passage was provided.
* @property {[number, number][]} [highlight] Zero-based ranges emphasized within the passage; left out, no passage text is emphasized.
* @property {string} [locator] Page or section locator shown below the passage; left out, no locator is shown.
* @property {string} [href] Absolute HTTP or HTTPS address used by the Open action; left out or invalid, Open appears only when onOpenSource is passed.
*/
/**
* @typedef {object} ActiveClaim
* @property {number|null} sentence Zero-based sentence index, or null to open a source that no sentence cites.
* @property {string|number} source Id of the source selected in the rail.
*/
/**
* @typedef {object} SourceRailProps
* @property {string} [question] Sets the heading above the answer; left out, the demo uses its sample question and host mode falls back to Answer, while passing it turns the demo off.
* @property {CitedSentence[]} [sentences] Supplies answer sentences and citation links; left out, the demo uses sample claims and host mode shows no answer text, while passing it turns the demo off.
* @property {CitedSource[]} [sources] Supplies source rows and evidence; left out, the demo uses sample sources and host mode shows none, while passing it turns the demo off.
* @property {ActiveClaim|null} [active] Controls the selected claim or source, with null closing the evidence pane; left out, selection is managed internally, while passing it turns the demo off.
* @property {ActiveClaim|null} [defaultActive] Sets the initial uncontrolled selection; left out, host mode selects the first valid cited claim, while passing it turns the demo off.
* @property {(active: ActiveClaim | null) => void} [onActiveChange] Reports claim and source selection changes; left out, uncontrolled selection still works locally.
* @property {(source: CitedSource) => void} [onOpenSource] Handles the Open action and makes it available without a valid href; left out, Open requires a valid source href.
* @property {string} [className] Adds classes to the outermost root; left out, only the built-in classes are used.
* @property {Partial<Tuning>} [tuning] Overrides rail measurements, timing and motion values; left out, every value in TUNING is used.
*/
```State
- every hook, handler, effect and disposal
- the animation loop, frame by frame
Tree
- the JSX, every className and inline style
Why · Remix · Check
- the mechanism, the tuning points, the checks
Unlock Cited Answer
The full source and the remix prompt ship with Premium.