Phase Gate

A pure decider that reveals its children when the current phase satisfies a predicate. The simplest building block for phased explainers, multi-step forms, and lesson screens that need to defer mounting one chunk of UI until the parent's "phase" pointer reaches it.

Reach for it whenever a section should appear, then stay, only inside a particular slice of a stepper — a "reveal the answer" panel that lives behind the predict phase, a hint band that should stay mounted across two phases but be hidden in between, a step body that swaps in and out under an opacity crossfade. The match shape grows from "reveal" to ["predict", "reveal"] to (p) => p.startsWith("step-") without changing component.

intropredictreveal

Closed — move the phase to reveal to see the panel.

Customize
State
predict
unmount
Behaviour

Installation

npx shadcn@latest add https://craftbits.dev/r/phase-gate.json

Usage

import { PhaseGate } from "@craft-bits/edu";
 
<PhaseGate phase={phase} match="reveal">
  <FinalAnswer />
</PhaseGate>

Match an allow-list when the panel should stay across several phases:

<PhaseGate phase={phase} match={["explain", "reveal"]} mode="hide">
  <HintPanel />
</PhaseGate>

Match a predicate for ranges or computed conditions:

<PhaseGate phase={phase} match={(p) => p.startsWith("step-")}>
  <StepBody />
</PhaseGate>

Provide a fallback to render closed-state content in the same slot:

<PhaseGate
  phase={phase}
  match="answered"
  fallback={<p>Pick an option to reveal the explanation.</p>}
>
  <Explanation />
</PhaseGate>

Anatomy

  • Root<div> with data-state="open" | "closed" and data-phase attributes. Spreads unknown props.
  • Open branch — children, rendered when match(phase) returns true. In mode="unmount" it enters through an opacity cross-fade; in mode="hide" it stays mounted with opacity-100 and full pointer events.
  • Closed branch — the fallback (or nothing). In mode="unmount" it exits the tree; in mode="hide" it sits absolute-positioned over the root with aria-hidden, inert, and pointer-events: none.
  • Cross-fade — 180ms easeOut opacity transition on enter and exit. Only opacity is animated. prefers-reduced-motion: reduce short-circuits to an instant swap, and disableAnimation skips it explicitly when a parent has already cross-faded the phase at a higher level.

Props

PropTypeDefaultDescription
phasestringrequiredThe current phase identifier.
matchstring | readonly string[] | (phase: string) => booleanrequiredPredicate against phase.
childrenReactNoderequiredRendered when the gate is open.
fallbackReactNodeRendered when the gate is closed. Omitted = nothing.
mode'unmount' | 'hide''unmount'Whether closed children leave the tree or just hide.
disableAnimationbooleanfalseSkip the cross-fade — useful when a parent already animates the phase change.
classNamestringMerged onto the root via cn().

PhaseGateMatch shapes: a string for equality, a readonly string[] for membership, or (phase: string) => boolean for arbitrary predicates.

Accessibility

  • The root carries data-state="open" | "closed" and data-phase, so consumers can target closed-state styling without subscribing to React state.
  • In mode="hide", the inactive branch carries aria-hidden="true" and inert so screen readers and the focus order both skip it cleanly. pointer-events: none blocks accidental clicks while the opacity fade runs.
  • In mode="unmount", the inactive branch is removed from the DOM — assistive tech only sees the active content. AnimatePresence with mode="popLayout" keeps the cross-fade smooth across the swap.
  • Motion only animates opacity — never width, height, top, or left. prefers-reduced-motion: reduce short-circuits the fade, so reduced-motion users see an instant swap.
  • The component is content-agnostic about its inner accessibility — pass children with the correct roles, labels, and focus management for whatever lives inside.

Credits

  • Extracted from: craftingattention (app/src/lessons/primitives/chrome/phase-gate.tsx). The source was a gate-registration context — a usePhaseGate(satisfied) hook plus a PhaseGateProvider that aggregated boolean satisfaction state across widgets so a lesson's "can the learner advance?" became the AND of every registered widget. craft-bits' PhaseGate reuses the name but inverts the responsibility: instead of widgets pushing satisfaction up to a phase, this component pulls a phase pointer down to decide whether to render. The aggregating context lives in the parent shell (Explainer, ArticleStack); this primitive is the reusable leaf — strip lessonId, strip the registry, accept a phase plus a match.