Play Pause
A single-button transport control. Toggles between a play triangle and a pause pair with a smooth path morph driven by Motion. Three visual variants (solid, ghost, outline), three sizes (sm, md, lg), and Radix-style controlled / uncontrolled APIs.
Customize
Style
solid
md
Installation
npx shadcn@latest add https://craftbits.dev/r/play-pause.jsonUsage
Uncontrolled — the button owns its own state:
import { PlayPause } from "@craft-bits/core";
<PlayPause defaultPlaying={false} onPlayingChange={(next) => console.log(next)} />Controlled — pair playing with onPlayingChange and the button mirrors your state:
const [playing, setPlaying] = useState(false);
<PlayPause playing={playing} onPlayingChange={setPlaying} />Understanding the component
- Single morphing path. The SVG renders one
motion.pathwhosedattribute is tweened between two strings — a triangle and a pair of bars. Both glyphs are drawn as two closed subpaths so the node count matches; without that, the morph would snap.SPRINGS.snap(the same spring as the tap response) drives the morph, so the icon flip and the press share a tempo. - Controlled + uncontrolled.
playing+onPlayingChangefor controlled,defaultPlaying+onPlayingChangefor uncontrolled. Same convention as@radix-ui/react-toggle. - Optical centering. A play triangle's geometric center sits ~12% left of its bounding-box center because the right edge is a point. The path is shifted right by ~1px inside the 24×24 viewBox so the glyph feels centered inside the circular button.
- CVA recipe.
playPauseVariantscomposes the base classes, the per-variant colors (bg-cb-accent, neutral surfaces, etc.), and the per-size dimensions (h-8/h-10/h-12). The prop signature is derived from the recipe viaVariantProps<typeof playPauseVariants>. - Accessible toggle semantics. The rendered
<button>carriesaria-pressed={playing}andaria-labelflips between"Play"(paused) and"Pause"(playing). Both labels can be overridden vialabels.data-state="playing" | "paused"is exposed for CSS and tests.
Variants
<PlayPause variant="solid" />
<PlayPause variant="ghost" />
<PlayPause variant="outline" />Sizes:
<PlayPause size="sm" />
<PlayPause size="md" />
<PlayPause size="lg" />Disabled — drops the tap gesture and the click handler:
<PlayPause disabled />Localized labels:
<PlayPause labels={{ play: "Start tour", pause: "Pause tour" }} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
playing | boolean | — | Controlled playing state. Pair with onPlayingChange. |
defaultPlaying | boolean | false | Uncontrolled initial playing state. |
onPlayingChange | (next: boolean) => void | — | Fired with the new state when the user toggles. |
variant | 'solid' | 'ghost' | 'outline' | 'solid' | Visual style of the button shell. |
size | 'sm' | 'md' | 'lg' | 'md' | Touch-target diameter (32 / 40 / 48 px). |
labels | { play?: string; pause?: string } | { play: "Play", pause: "Pause" } | Override the accessible labels. |
disabled | boolean | false | Disables the button and drops the tap gesture. |
className | string | — | Merged onto the rendered <button> via cn(). |
...rest | HTMLMotionProps<'button'> | — | Any other motion.button prop (onClick, aria-*, data-*, etc.). |
Accessibility
- Renders a real
<button type="button">witharia-pressedreflecting the current state — screen readers announce both the action ("Play" / "Pause") and the toggle state. - Keyboard activation:
EnterandSpacetoggle the button, same as any native<button>. - Focus is visible via a
focus-visible:ring keyed to--cb-accentso keyboard users always see the current target. - Minimum hit area: every size meets Fitts ≥ 32×32 (sm = 32, md = 40, lg = 48).
- The morphing icon respects
prefers-reduced-motion: Motion's spring transition collapses to an instant swap. - Color contrast:
solidplaces--cb-accent-fgon--cb-accent;outlineuses--cb-fgon--cb-bg-elevated; both pass WCAG AA in the default theme.
Credits
- Extracted from:
algoflashcards(src/lessons/primitives/interaction/PlayPauseControl.tsx). The source was a project-specific control coupled to the lesson playback model; craft-bits generalizes it into a CVA-driven primitive with three variants, three sizes, a single morphing path, and Radix-style controlled / uncontrolled APIs.