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.json

Usage

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

  1. Keyed cross-fade. The body is wrapped in <AnimatePresence mode="popLayout" initial={false}> and keyed by phase. When phase changes, the previous body fades + slides out by 6px while the next body fades + slides in from the same offset — both in parallel under SPRINGS.smooth.
  2. 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.
  3. 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.
  4. Direction. The direction prop swaps the y-offset sign — up (default) for forward motion, down for back, none for a pure opacity fade.
  5. Reduced motion. When prefers-reduced-motion: reduce is set, the y-offset collapses to 0 and the transition duration drops to 0 — the swap becomes instant.
  6. Aria-live polite. The body region carries aria-live="polite" so screen readers re-announce the new content when phase advances, without interrupting the user.

Props

PropTypeDefaultDescription
phasestring | numberrequiredStable identifier for the currently visible phase. Drives the AnimatePresence key.
labelReactNodeOptional caption above the body. Pass empty string or omit to hide.
childrenReactNoderequiredBody for the current phase.
direction'up' | 'down' | 'none''up'Direction the outgoing phase travels on exit.
barebooleanfalseDrop the rounded panel chrome and render the cross-fade inline.
classNamestringMerged onto the root via cn().

Accessibility

  • The body wrapper is role="group" with aria-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: none so a mid-transition pointer cannot interact with content that is on its way out.
  • prefers-reduced-motion: reduce short-circuits both the y-offset and the spring — the swap becomes instant.
  • The visible label, when present, uses the cb-label token at text-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 sibling PhaseRibbon.