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.

Phase 1 / Intro

Open with a question or a hook. The body lives wherever you want — drop your own chrome inside each Phase child.

Customize
Layout

Installation

npx shadcn@latest add https://craftbits.dev/r/explainer.json

Usage

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

  1. Marker children. Phase and Completion never render themselves — Root walks children (flattening Fragments and mapped arrays), collects markers by type, and renders their bodies in phase-tuple order. A Completion claims the terminal phase slot; an explicit terminal Phase of the same name is replaced.
  2. Render-fn children. Each Phase or Completion accepts either a raw ReactNode or a render function receiving the context. The render-fn form lets each step wire its own advance / back / reset buttons without lifting state.
  3. Controlled + uncontrolled. Omit activePhase and the Root owns the state. Pass activePhase + onPhaseChange and the parent owns it. Both forms expose the same useExplainer() context to nested children.
  4. Backward-only goTo. ctx.goTo(target) jumps to a previously reached phase. Forward motion requires advance() so consumer state (score, history) cannot fall out of sync with skipped phases.
  5. Reset plumbing. ctx.reset() (or bumping resetSignal) jumps to the initial phase, resets highest, and fires onReset so the parent can wipe derived state. The initial-mount resetSignal is captured and never fires the reset path on first render.
  6. showAll review mode. When showAll is true, every Phase + Completion renders at once, isCompleted starts true, and highest maxes out — the shell becomes a stacked review surface without re-wiring child trees.

Props

Explainer.Root

PropTypeDefaultDescription
phasesreadonly P[]requiredPhase tuple. Declare as const to narrow P to a literal union.
defaultPhasePphases[0]Initial phase for uncontrolled flows. Ignored when activePhase is set.
activePhasePControlled current phase. Pair with onPhaseChange.
resetSignalnumberExternal reset trigger. Bumping the value fires the reset path.
showAllbooleanfalseRender every Phase + Completion at once. Start at the terminal phase.
onPhaseChange(phase, index) => voidFires on every transition. NOT on initial mount.
onReset() => voidFires after ctx.reset() or resetSignal bump.
childrenReactNoderequiredPhase / Completion markers.
classNamestringMerged onto the root <article> via cn().

Explainer.Phase

PropTypeDefaultDescription
namePrequiredMust be one of the strings in the parent Root's phases tuple.
titleReactNodeOptional header rendered above the body.
childrenReactNode | (ctx) => ReactNoderequiredPhase body. Function form receives the ctx.

Explainer.Completion

PropTypeDefaultDescription
titleReactNodeOptional header for the terminal card.
childrenReactNode | (ctx) => ReactNoderequiredCompletion body. Function form receives the ctx.

useExplainer() context

FieldTypeDescription
phasePCurrent phase name.
phaseIndexnumberOrdinal index into phases.
phasesreadonly P[]Tuple passed to Root.
advance() => voidMove to the next phase. No-op at the last phase.
back() => voidStep back one phase. No-op at phase 0.
goTo(phase: P) => voidBackward-only jump. No-op when the target exceeds highest.
reset() => voidReset to the initial phase + fire onReset.
showAllbooleanMirrors the Root prop.
isCompletedbooleanTerminal 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> with data-phase-id / data-phase-active; the active one carries aria-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 mystery undefined.

Credits

  • Extracted from: algoflashcards (src/lessons/primitives/chrome/Explainer.tsx). The source coupled the compound to lesson-specific concerns — ExplainerTrackingContext, PhaseGateCtx, RichText, SEMANTIC_HEX track theming, and the project's ArticleStack shell. 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.