Open with a question or a hook. The body lives wherever you want — drop your own chrome inside each Phase child.
Explainer
A compound primitive for phase-driven shells — lessons, multi-step explainers, tutorial walkthroughs, deploy timelines. Root owns the phase-state machine and renders one named Phase (or the terminal Completion) at a time. The shell makes no assumption about styling beyond a stack-of-sections layout, so drop your own chrome inside each child.
Different from PhaseTransition, which cross-fades a single body between named phases: Explainer is the whole shell — phase declaration, navigation, review mode, reset — leaving content rendering to the caller.
Installation
npx shadcn@latest add https://craftbits.dev/r/explainer.jsonUsage
import { Explainer } from "@craft-bits/core";
<Explainer.Root phases={["intro", "play", "recap"] as const}>
<Explainer.Phase name="intro" title="Phase 1 / Intro">
{(ctx) => <button onClick={ctx.advance}>Start</button>}
</Explainer.Phase>
<Explainer.Phase name="play" title="Phase 2 / Play">
Interactive body here.
</Explainer.Phase>
<Explainer.Completion title="Phase 3 / Recap">
{(ctx) => <button onClick={ctx.reset}>Replay</button>}
</Explainer.Completion>
</Explainer.Root>Declare the phases tuple as const so the phase names narrow to a literal-string union — name and goTo arguments then typecheck against the exact set.
Render every phase at once for a review-all view by passing showAll:
<Explainer.Root phases={PHASES} showAll>
...
</Explainer.Root>Drive the flow externally by pairing activePhase with onPhaseChange:
const [phase, setPhase] = useState("intro");
<Explainer.Root
phases={["intro", "play", "recap"] as const}
activePhase={phase}
onPhaseChange={(next) => setPhase(next)}
>
...
</Explainer.Root>Understanding the component
- Marker children.
PhaseandCompletionnever render themselves — Root walkschildren(flattening Fragments and mapped arrays), collects markers by type, and renders their bodies in phase-tuple order. ACompletionclaims the terminal phase slot; an explicit terminalPhaseof the same name is replaced. - Render-fn children. Each Phase or Completion accepts either a raw
ReactNodeor a render function receiving the context. The render-fn form lets each step wire its ownadvance/back/resetbuttons without lifting state. - Controlled + uncontrolled. Omit
activePhaseand the Root owns the state. PassactivePhase+onPhaseChangeand the parent owns it. Both forms expose the sameuseExplainer()context to nested children. - Backward-only goTo.
ctx.goTo(target)jumps to a previously reached phase. Forward motion requiresadvance()so consumer state (score, history) cannot fall out of sync with skipped phases. - Reset plumbing.
ctx.reset()(or bumpingresetSignal) jumps to the initial phase, resetshighest, and firesonResetso the parent can wipe derived state. The initial-mountresetSignalis captured and never fires the reset path on first render. - showAll review mode. When
showAllis true, every Phase + Completion renders at once,isCompletedstarts true, andhighestmaxes out — the shell becomes a stacked review surface without re-wiring child trees.
Props
Explainer.Root
| Prop | Type | Default | Description |
|---|---|---|---|
phases | readonly P[] | required | Phase tuple. Declare as const to narrow P to a literal union. |
defaultPhase | P | phases[0] | Initial phase for uncontrolled flows. Ignored when activePhase is set. |
activePhase | P | — | Controlled current phase. Pair with onPhaseChange. |
resetSignal | number | — | External reset trigger. Bumping the value fires the reset path. |
showAll | boolean | false | Render every Phase + Completion at once. Start at the terminal phase. |
onPhaseChange | (phase, index) => void | — | Fires on every transition. NOT on initial mount. |
onReset | () => void | — | Fires after ctx.reset() or resetSignal bump. |
children | ReactNode | required | Phase / Completion markers. |
className | string | — | Merged onto the root <article> via cn(). |
Explainer.Phase
| Prop | Type | Default | Description |
|---|---|---|---|
name | P | required | Must be one of the strings in the parent Root's phases tuple. |
title | ReactNode | — | Optional header rendered above the body. |
children | ReactNode | (ctx) => ReactNode | required | Phase body. Function form receives the ctx. |
Explainer.Completion
| Prop | Type | Default | Description |
|---|---|---|---|
title | ReactNode | — | Optional header for the terminal card. |
children | ReactNode | (ctx) => ReactNode | required | Completion body. Function form receives the ctx. |
useExplainer() context
| Field | Type | Description |
|---|---|---|
phase | P | Current phase name. |
phaseIndex | number | Ordinal index into phases. |
phases | readonly P[] | Tuple passed to Root. |
advance | () => void | Move to the next phase. No-op at the last phase. |
back | () => void | Step back one phase. No-op at phase 0. |
goTo | (phase: P) => void | Backward-only jump. No-op when the target exceeds highest. |
reset | () => void | Reset to the initial phase + fire onReset. |
showAll | boolean | Mirrors the Root prop. |
isCompleted | boolean | Terminal phase reached. |
Accessibility
- The root renders as an
<article>landmark so assistive tech can jump to the whole explainer. - Each visible Phase renders as a
<section>withdata-phase-id/data-phase-active; the active one carriesaria-current="step"so screen readers announce position. - No motion is introduced — wrap the body in
PhaseTransition(sibling primitive) if you want a cross-fade between phases. Reduced-motion respect is delegated to the child you nest. useExplainer()throws when called outside Root, so violations surface eagerly rather than via a mysteryundefined.
Credits
- Extracted from:
algoflashcards(src/lessons/primitives/chrome/Explainer.tsx). The source coupled the compound to lesson-specific concerns —ExplainerTrackingContext,PhaseGateCtx,RichText,SEMANTIC_HEXtrack theming, and the project'sArticleStackshell. craft-bits' version strips all of that, exposes a generic phases tuple, adds controlled + uncontrolled support, and renders a plain<article>/<section>stack so consumers compose their own chrome inside each Phase.