Step Caption
The single-line caption that sits beneath a visualization and tells the reader what just happened (or what is about to). When the step changes, the previous caption cross-fades out by 6px and the next caption fades in from the same offset under SPRINGS.smooth.
Reach for it when an animated visual needs a narration line that turns over on every step — algorithm traces, walkthroughs, multi-phase explainers. Sibling to PhaseTransition, which morphs an entire body region; StepCaption morphs only a single narration line, without chrome.
Step 2 / 4
Compare each element to the current max — replace when it is larger.
Customize
Step
compare
Chrome
Installation
npx shadcn@latest add https://craftbits.dev/r/step-caption.jsonUsage
import { StepCaption } from "@craft-bits/core";
<StepCaption
stepKey={step.id}
kicker="Step 2 / 4"
text="Compare each element to the current max — replace when it is larger."
/>Drop the kicker for a bare narration line:
<StepCaption stepKey={step.id} text={step.caption} />Mark the takeaway caption with the accent color:
<StepCaption stepKey="done" text="The array is unchanged." accent />Understanding the component
- Keyed cross-fade. The caption is wrapped in
<AnimatePresence mode="popLayout" initial={false}>and keyed bystepKey(defaulting to the stringifiedtext). When the key changes, the previous caption fades + slides out by 6px while the next fades in from the same offset — both in parallel underSPRINGS.smooth. initial={false}. The first render does not animate; only subsequent step changes do. Skips the first-render fade so the page does not flicker on load.mode="popLayout". Old and new captions coexist mid-transition without pushing siblings around — a tall caption cross-fading with a short one does not cause sibling content to twitch.- Two regions, one rhythm. The optional kicker and the body share the same key and transition, so the row breathes together rather than the kicker arriving on a different beat.
- Reduced motion. When
prefers-reduced-motion: reduceis set, the y-offset collapses to 0 and the transition duration drops to 0 — the swap becomes instant. - Aria-live polite. The body region carries
role="status"andaria-live="polite"so screen readers re-announce the new caption when the step advances, without interrupting the user.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
text | ReactNode | required | Caption body for the current step. |
kicker | ReactNode | — | Optional small prefix label above the body. Omit to render only the body. |
stepKey | string | number | derived from text | Stable AnimatePresence key. Override when text changes mid-step but the cross-fade should not. |
accent | boolean | false | Render the body in the accent foreground color instead of the default muted body color. |
className | string | — | Merged onto the root <div> via cn(). |
Accessibility
- The caption is announced via
role="status"+aria-live="polite"so screen readers re-read the new content on swap without interrupting. - Animation is transform + opacity only — never
width/height/top/left. - Exiting captions receive
pointer-events: noneso a mid-transition pointer cannot interact with content that is on its way out. prefers-reduced-motion: reduceshort-circuits both the y-offset and the spring — the swap becomes instant.- The default
text-cb-fg-mutedbody color and thetext-cb-accentaccent variant meet WCAG AA contrast against the default surface; verify with custom palettes.
Credits
- Extracted from:
algoflashcards(src/lessons/primitives/chrome/StepCaption.tsx). The source coupled the caption to RichText narration and a bag-derived stepKey. craft-bits' version drops that plumbing, surfaces an optional kicker, and adds an accent flag so the same primitive can also serve as the takeaway-line variant.