> Premium standalone component (part of no design system, works in any React + Tailwind app) from AI Canvas. Index of every component and install setup: https://aicanvas.me/llms.txt

# Cited Answer

An answer with numbered citations: pick a number or a source to see the quoted passage and whether it really backs the sentence.

- Category: AI Agent UI
- Built with: Motion, Tailwind CSS
- License: Premium (proprietary, not in the public repo)
- Page: https://aicanvas.me/components/source-rail

## Install

```bash
npx shadcn@latest add @aicanvas/source-rail
```

Requires Premium. Set your AI Canvas token as AICANVAS_TOKEN and register the @aicanvas registry with it, as described in the Install section of https://aicanvas.me/llms.txt. Without a Premium token the command writes a placeholder file instead of the component.

AI agents can also get its install command from the read-only AI Canvas MCP server (`npx -y @aicanvas/mcp`) and run it. Setup: https://aicanvas.me/mcp

Peer dependencies:

```bash
npm install framer-motion
```

## Props

### CitedSentence

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` | `string` |  | Answer sentence shown in the claim text. |
| `cites?` | `(string\|number)[]` |  | Source ids attached as numbered citations; left out, the sentence is flagged as having no source. |

### CitedSource

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string\|number` |  | Stable source identifier that must be unique within the sources list. |
| `title` | `string` |  | Source title shown in the rail and evidence pane. |
| `domain` | `string` |  | Source domain shown below its title. |
| `verdict?` | `Verdict` |  | Labels cited evidence as supporting, partial or contradictory; left out, no verdict tag is shown. |
| `quote?` | `string` |  | Passage shown when the source backs a claim; left out, a cited source says that no passage was provided. |
| `highlight?` | `[number, number][]` |  | Zero-based ranges emphasized within the passage; left out, no passage text is emphasized. |
| `locator?` | `string` |  | Page or section locator shown below the passage; left out, no locator is shown. |
| `href?` | `string` |  | Absolute HTTP or HTTPS address used by the Open action; left out or invalid, Open appears only when onOpenSource is passed. |

### ActiveClaim

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `sentence` | `number\|null` |  | Zero-based sentence index, or null to open a source that no sentence cites. |
| `source` | `string\|number` |  | Id of the source selected in the rail. |

### SourceRail

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `question?` | `string` |  | 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. |
| `sentences?` | `CitedSentence[]` |  | 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. |
| `sources?` | `CitedSource[]` |  | Supplies source rows and evidence; left out, the demo uses sample sources and host mode shows none, while passing it turns the demo off. |
| `active?` | `ActiveClaim\|null` |  | 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. |
| `defaultActive?` | `ActiveClaim\|null` |  | Sets the initial uncontrolled selection; left out, host mode selects the first valid cited claim, while passing it turns the demo off. |
| `onActiveChange?` | `(active: ActiveClaim \| null) => void` |  | Reports claim and source selection changes; left out, uncontrolled selection still works locally. |
| `onOpenSource?` | `(source: CitedSource) => void` |  | Handles the Open action and makes it available without a valid href; left out, Open requires a valid source href. |
| `className?` | `string` |  | Adds classes to the outermost root; left out, only the built-in classes are used. |
| `tuning?` | `Partial<Tuning>` |  | Overrides rail measurements, timing and motion values; left out, every value in TUNING is used. |

## About

Source Rail puts citations next to an agent answer: numbered markers inside the text, a rail of sources beside it, and hover cards with the exact quoted passage. Hovering a marker highlights its source and hovering a source lights every marker that cites it, each marker can show how directly its source supports the claim, and on narrow screens the rail folds into a strip of chips that open the quote as a sheet. Markers sit in the tab order, Enter opens a card, arrows move through the rail. Sources and text are props; without them a demo walks through its own citations.

Use cases: Research assistants, AI search, Documentation bots.

## Source

The source is Premium and is not published. Preview it at https://aicanvas.me/components/source-rail.

## Remix prompt

The public part of the remix prompt: 1. Setup and 2. Constants. The rest of the build spec (state, markup, how it works) is part of Premium.

````markdown
# 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.
 */
```
````

## Related

- [Streaming Answer](https://aicanvas.me/components/token-stream.md): An assistant answer that streams in word by word, already formatted, with new words fading in, a stop and continue control, and message actions.
- [Thinking Trace](https://aicanvas.me/components/reasoning-trace.md): A single steady line shows what an agent is thinking right now, then settles into how long it thought, with every step one click away.
- [Permission Request](https://aicanvas.me/components/permission-scope.md): A permission card where an agent asks for access and you allow it once, for this chat or always for a pattern, then revoke it from the receipt.
- [Agent Status](https://aicanvas.me/components/agent-status.md): A status pill that says what the agent is doing, whose move it is, time and cost, and opens to the step history and token bill.
- [Code Block](https://aicanvas.me/components/code-panel.md): Code an agent just wrote streams in line by line, then numbered markers tie its notes to the exact lines, with Copy and Apply.
