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.
Closed — move the phase to reveal to see the panel.
Installation
npx shadcn@latest add https://craftbits.dev/r/phase-gate.jsonUsage
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>withdata-state="open" | "closed"anddata-phaseattributes. Spreads unknown props. - Open branch — children, rendered when
match(phase)returns true. Inmode="unmount"it enters through an opacity cross-fade; inmode="hide"it stays mounted withopacity-100and full pointer events. - Closed branch — the
fallback(or nothing). Inmode="unmount"it exits the tree; inmode="hide"it sits absolute-positioned over the root witharia-hidden,inert, andpointer-events: none. - Cross-fade — 180ms
easeOutopacity transition on enter and exit. Onlyopacityis animated.prefers-reduced-motion: reduceshort-circuits to an instant swap, anddisableAnimationskips it explicitly when a parent has already cross-faded the phase at a higher level.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
phase | string | required | The current phase identifier. |
match | string | readonly string[] | (phase: string) => boolean | required | Predicate against phase. |
children | ReactNode | required | Rendered when the gate is open. |
fallback | ReactNode | — | Rendered when the gate is closed. Omitted = nothing. |
mode | 'unmount' | 'hide' | 'unmount' | Whether closed children leave the tree or just hide. |
disableAnimation | boolean | false | Skip the cross-fade — useful when a parent already animates the phase change. |
className | string | — | Merged 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"anddata-phase, so consumers can target closed-state styling without subscribing to React state. - In
mode="hide", the inactive branch carriesaria-hidden="true"andinertso screen readers and the focus order both skip it cleanly.pointer-events: noneblocks 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.AnimatePresencewithmode="popLayout"keeps the cross-fade smooth across the swap. - Motion only animates
opacity— neverwidth,height,top, orleft.prefers-reduced-motion: reduceshort-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 — ausePhaseGate(satisfied)hook plus aPhaseGateProviderthat aggregated boolean satisfaction state across widgets so a lesson's "can the learner advance?" became the AND of every registered widget. craft-bits'PhaseGatereuses 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 — striplessonId, strip the registry, accept aphaseplus amatch.