Phase Transition
The companion to PhaseRibbon. The ribbon advertises the map of phases across the top of a page; PhaseTransition morphs the body when the active phase changes. It owns no phase state — the parent picks the current phase and renders the matching children. The wrapper handles the AnimatePresence cross-fade so consecutive phase bodies never collide visually.
Reach for it when a flow has named phases whose content swaps in place — lessons, deploy pipelines, wizards, multi-step explainers. Pair it with PhaseRibbon by giving both the same phase key.
Phase 2 / Build
Compile sources, bundle assets, and assemble the output artifact for downstream phases.
Customize
Phase
build
Motion
up
Chrome
Installation
npx shadcn@latest add https://craftbits.dev/r/phase-transition.jsonUsage
import { PhaseTransition } from "@craft-bits/core";
<PhaseTransition phase={currentPhase} label="Phase 2 / Build">
Compile sources and bundle assets for downstream phases.
</PhaseTransition>Drop the panel chrome to render the cross-fade inline (bare):
<PhaseTransition phase={currentPhase} bare>
{bodyForPhase(currentPhase)}
</PhaseTransition>Reverse the exit direction when navigating backwards through phases:
<PhaseTransition phase={currentPhase} direction="down">
{bodyForPhase(currentPhase)}
</PhaseTransition>Pair with PhaseRibbon by sharing one piece of state:
const [phase, setPhase] = useState("build");
<PhaseRibbon
currentPhase={phase}
onCurrentPhaseChange={setPhase}
interactive
phases={PHASES}
/>
<PhaseTransition phase={phase} label={PHASES.find((p) => p.id === phase)?.label}>
{bodyForPhase(phase)}
</PhaseTransition>Understanding the component
- Keyed cross-fade. The body is wrapped in
<AnimatePresence mode="popLayout" initial={false}>and keyed byphase. Whenphasechanges, the previous body fades + slides out by 6px while the next body fades + slides in from the same offset — both in parallel underSPRINGS.smooth. initial={false}. The first render does not animate; only subsequent phase changes do. Skips the first-render fade so the page does not flicker on load.mode="popLayout". Old and new bodies coexist mid-transition without pushing siblings around — a tall body cross-fading with a short one does not cause sibling content to twitch.- Direction. The
directionprop swaps the y-offset sign —up(default) for forward motion,downfor back,nonefor a pure opacity fade. - 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
aria-live="polite"so screen readers re-announce the new content whenphaseadvances, without interrupting the user.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
phase | string | number | required | Stable identifier for the currently visible phase. Drives the AnimatePresence key. |
label | ReactNode | — | Optional caption above the body. Pass empty string or omit to hide. |
children | ReactNode | required | Body for the current phase. |
direction | 'up' | 'down' | 'none' | 'up' | Direction the outgoing phase travels on exit. |
bare | boolean | false | Drop the rounded panel chrome and render the cross-fade inline. |
className | string | — | Merged onto the root via cn(). |
Accessibility
- The body wrapper is
role="group"witharia-live="polite"so screen readers announce the new phase content on swap without interrupting. - Animation is transform + opacity only — never
width/height/top/left. - Exiting bodies 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 visible label, when present, uses the
cb-labeltoken attext-cb-fg-muted, which meets WCAG AA contrast in the default theme against the default surface.
Credits
- Extracted from:
algoflashcards(src/lessons/primitives/chrome/PhaseTransition.tsx). The source coupled the cross-fade to a lesson's phase reducer + RichText narration. craft-bits' version strips that plumbing and exposes a generic two-prop API (phase,children) so the same primitive composes with any state machine — including the siblingPhaseRibbon.