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

Usage

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

  1. Single morphing path. The SVG renders one motion.path whose d attribute 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.
  2. Controlled + uncontrolled. playing + onPlayingChange for controlled, defaultPlaying + onPlayingChange for uncontrolled. Same convention as @radix-ui/react-toggle.
  3. 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.
  4. CVA recipe. playPauseVariants composes 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 via VariantProps<typeof playPauseVariants>.
  5. Accessible toggle semantics. The rendered <button> carries aria-pressed={playing} and aria-label flips between "Play" (paused) and "Pause" (playing). Both labels can be overridden via labels. 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

PropTypeDefaultDescription
playingbooleanControlled playing state. Pair with onPlayingChange.
defaultPlayingbooleanfalseUncontrolled initial playing state.
onPlayingChange(next: boolean) => voidFired 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.
disabledbooleanfalseDisables the button and drops the tap gesture.
classNamestringMerged onto the rendered <button> via cn().
...restHTMLMotionProps<'button'>Any other motion.button prop (onClick, aria-*, data-*, etc.).

Accessibility

  • Renders a real <button type="button"> with aria-pressed reflecting the current state — screen readers announce both the action ("Play" / "Pause") and the toggle state.
  • Keyboard activation: Enter and Space toggle the button, same as any native <button>.
  • Focus is visible via a focus-visible: ring keyed to --cb-accent so 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: solid places --cb-accent-fg on --cb-accent; outline uses --cb-fg on --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.