# Reasoning Trace
An expandable AI reasoning card with a live headline ticker, step timeline, sources and final answer.
## 1. Setup
// npm install framer-motion
File: components-workspace/reasoning-trace/index.tsx · 'use client' · export default function ReasoningTrace()
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 { ChevronDown } from '@/app/_icons/icons/chevron-down'
import { SparkleDuotone } from '@/app/_icons/icons/sparkle-duotone'
import { CircleStopDuotone } from '@/app/_icons/icons/circle-stop-duotone'
import { FileTextDuotone } from '@/app/_icons/icons/file-text-duotone'
import { GlobeDuotone } from '@/app/_icons/icons/globe-duotone'
Styling is Tailwind CSS v4 (the bg-(--var) and text-(color:--var) syntax, pointer-coarse: and class-based dark:). The @/app/_icons imports are the AI Canvas icon set; without it, use any icons that accept the same size, weight and className props.
## 2. Constants
export type Tuning = {
cardMaxW: number
rowH: number
iconPx: number
dotPx: number
stepGapPx: number
stopPx: number
tickerMs: number
tickerRisePx: number
stateMs: number
panelMs: number
hoverMs: number
lightMs: number
answerDelayMs: number
replayDelayMs: number
wordFadeMs: number
wordStepMs: number
flowCapMs: number
shimmerS: number
pulseS: number
pressScale: number
revealShare: number
dotPitch: number
dotFieldH: number
fieldRoomPx: number
tickMs: number
stopHoldMs: number
firstThoughtMs: number
thoughtEveryMs: number
thinkEndMs: number
demoSpeed: number
}
export const TUNING: Tuning = {
cardMaxW: 480,
rowH: 44,
iconPx: 16,
dotPx: 6,
stepGapPx: 12,
stopPx: 32,
tickerMs: 220,
tickerRisePx: 4,
stateMs: 200,
panelMs: 280,
hoverMs: 150,
lightMs: 600,
answerDelayMs: 360,
replayDelayMs: 700,
wordFadeMs: 240,
wordStepMs: 40,
flowCapMs: 900,
shimmerS: 2.6,
pulseS: 1.2,
pressScale: 0.97,
revealShare: 0.75,
dotPitch: 6,
dotFieldH: 64,
fieldRoomPx: 56,
tickMs: 100,
stopHoldMs: 5000,
firstThoughtMs: 600,
thoughtEveryMs: 2000,
thinkEndMs: 8600,
demoSpeed: 1,
}
const EASE = [0.23, 1, 0.32, 1] as const
// Keeps a number and its unit on one line ("4 steps").
const NBSP = String.fromCharCode(160)
export type ThoughtStep = {
headline: string
body?: string
sources?: string[]
}
type Phase = 'thinking' | 'done' | 'stopped'
type View = Phase | 'idle'
type ReasoningTraceProps = {
steps?: ThoughtStep[]
status?: Phase
elapsedMs?: number
label?: string
defaultOpen?: boolean
answer?: React.ReactNode
onToggle?: (open: boolean) => void
onStop?: () => void
className?: string
tuning?: Partial<Tuning>
}
const STORY_STEPS: ThoughtStep[] = [
{
headline: 'Reading the failing test',
body: 'The test expects 09:00 in Tokyo but gets 00:00. That is exactly the 9 hour offset.',
},
{
headline: 'Looking for where dates are parsed',
body: 'parseDate is called from three places; only the report path strips the offset.',
sources: ['docs.example.dev', 'src/date.ts'],
},
{
headline: 'Comparing two fixes',
body: 'Converting at the edges would touch 12 files. Keeping the offset inside parseDate touches one.',
},
{
headline: 'Checking the edge cases',
body: 'Empty strings and dates without an offset still need to parse as UTC.',
},
]
// Reduced motion as an external store: server and hydrating render both read false.
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
}
function fmtClock(ms: number) {
const s = Math.floor(ms / 1000)
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
}
function fmtThought(ms: number) {
const s = Math.max(1, Math.round(ms / 1000))
if (s < 60) return `${s}s`
return `${Math.floor(s / 60)}m${NBSP}${String(s % 60).padStart(2, '0')}s`
}
function spokenThought(ms: number) {
const s = Math.max(1, Math.round(ms / 1000))
return `${s} second${s === 1 ? '' : 's'}`
}
const clamp01 = (v: number) => Math.min(1, Math.max(0, v))
const isWeb = (src: string) => /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(src.split('/')[0])
// Measured before paint and only once laid out, so a remount never animates from 0.
function useHeight<T extends HTMLElement>() {
const ref = useRef<T>(null)
const [h, setH] = useState<number | null>(null)
useLayoutEffect(() => {
const el = ref.current
if (!el) return
const read = () => {
if (el.offsetWidth > 0) setH(el.offsetHeight)
}
read()
const ro = new ResizeObserver(read)
ro.observe(el)
return () => ro.disconnect()
}, [])
return [ref, h] as const
}
const THEME_VARS = [
'[--rt-page:#EEEEF3] dark:[--rt-page:#0E0E0F]',
'[--rt-panel:#F4F4FA] dark:[--rt-panel:#1B1B1C]',
'[--rt-well:#EEEEF3] dark:[--rt-well:#0E0E0F]',
'[--rt-raised:#DFDFE3] dark:[--rt-raised:#2D2D2E]',
'[--rt-raised-hover:#CACACD] dark:[--rt-raised-hover:#373738]',
'[--rt-border:#DFDFE3] dark:[--rt-border:#2D2D2E]',
'[--rt-border-strong:#CACACD] dark:[--rt-border-strong:#373738]',
'[--rt-hairline:#DFDFE3] dark:[--rt-hairline:#2D2D2E]',
'[--rt-rim:#CACACD] dark:[--rt-rim:#F4F4FA2E]',
'[--rt-text:#1B1B1C] dark:[--rt-text:#F4F4FA]',
'[--rt-body:#373738] dark:[--rt-body:#CACACD]',
'[--rt-muted:#575759] dark:[--rt-muted:#9B9B9E]',
'[--rt-faint:#7B7B7D]',
'[--rt-selection:#A0A0A233] dark:[--rt-selection:#5E5E6333]',
'[--rt-success:#047857] dark:[--rt-success:#34D399]',
'[--rt-ai:#0369A1] dark:[--rt-ai:#7DD3FC]',
'[--rt-attention:#B45309] dark:[--rt-attention:#FFB574]',
'[--rt-red:#C8262C] dark:[--rt-red:#E5484D]',
'[--rt-focus:#1B1B1C99] dark:[--rt-focus:#F4F4FA99]',
'[--rt-focus-gap:#F4F4FA] dark:[--rt-focus-gap:#1B1B1C]',
'[--rt-dot:#1B1B1C] dark:[--rt-dot:#F4F4FA]',
'[--rt-elev-card:0_2px_6px_rgba(0,0,0,0.06),0_28px_56px_-16px_rgba(0,0,0,0.25)] dark:[--rt-elev-card:0_2px_6px_rgba(0,0,0,0.5),0_36px_72px_-18px_rgba(0,0,0,0.9)]',
].join(' ')
const CARD =
'relative w-full rounded-[24px] border border-(color:--rt-border) bg-(--rt-panel) p-4 min-[400px]:p-5 shadow-(--rt-elev-card)'
const WELL = 'rounded-2xl border border-(color:--rt-border) bg-(--rt-well)'
const META = 'shrink-0 text-[12px] leading-4 tabular-nums text-(color:--rt-muted)'
const MONO = 'font-mono text-[12px] leading-[18px]'
const FOCUS =
'outline-none focus-visible:ring-2 focus-visible:ring-(color:--rt-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(color:--rt-focus-gap)'
// Touch target via pseudo-element: 32px buttons reach 44px, the 32px Stop reaches 48px.
const HIT = "relative pointer-coarse:after:absolute pointer-coarse:after:content-['']"
const HIT_BTN = `${HIT} pointer-coarse:after:-inset-1.5`
const HIT_STOP = `${HIT} pointer-coarse:after:-inset-2`
const BTN =
'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 disabled:opacity-50'
const BTN_GHOST = `${BTN} text-(color:--rt-muted) hover:bg-(--rt-raised) hover:text-(color:--rt-text)`
type Tone = 'neutral' | 'ai' | 'attention' | 'success' | 'red'
const TONE_VAR: Record<Tone, string> = {
neutral: 'var(--rt-dot)',
ai: 'var(--rt-ai)',
attention: 'var(--rt-attention)',
success: 'var(--rt-success)',
red: 'var(--rt-red)',
}
const TONE_STRENGTH: Record<Tone, number> = { neutral: 6, ai: 14, attention: 14, success: 10, red: 12 }
const SHIMMER_BG = 'linear-gradient(90deg, var(--rt-muted) 0%, var(--rt-text) 50%, var(--rt-muted) 100%)'
/**
* @typedef {object} ThoughtStep
* @property {string} headline Summary shown for the reasoning step.
* @property {string} [body] Supporting explanation revealed when the trace is open; left out, only the headline is shown.
* @property {string[]} [sources] Source labels shown beneath the explanation; left out, no sources are shown for the step.
*/
/**
* @typedef {object} ReasoningTraceProps
* @property {ThoughtStep[]} [steps] Supplies the reasoning timeline; left out, host mode has no steps, while passing it turns the demo off.
* @property {'thinking'|'done'|'stopped'} [status] Sets the trace state; left out, host mode is idle, while passing it turns the demo off.
* @property {number} [elapsedMs] Shows the thinking or stopped duration in milliseconds; left out, no duration is shown in host mode, while passing it turns the demo off.
* @property {string} [label] Adds a label beside the trace status; left out, no label is shown, while passing it turns the demo off.
* @property {boolean} [defaultOpen=false] Sets whether the reasoning steps start expanded.
* @property {React.ReactNode} [answer] Renders answer content below the trace whenever it is not thinking; left out, host mode shows no answer, while passing it turns the demo off.
* @property {(open: boolean) => void} [onToggle] Reports expansion changes while open state remains local; left out, expansion changes are not reported.
* @property {() => void} [onStop] Handles stopping and makes the stop control available while thinking; left out, host traces have no stop control.
* @property {string} [className] Adds classes to the outermost root; left out, only the built-in classes are used.
* @property {Partial<Tuning>} [tuning] Overrides trace 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 Thinking Trace
The full source and the remix prompt ship with Premium.