{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "play-button",
  "title": "Play Button",
  "description": "A circular media toggle whose glyph morphs between a play triangle and pause bars, with a ghost or frosted over-video surface.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/base/ui/play-button.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  type Transition,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n// YouTube's play button is one `<path>` whose `d` is rewritten frame by frame,\n// and the shape of that data is the whole trick: the play triangle is split\n// down x=17 into two sub-paths, each written as a four-corner rounded quad.\n// The pause icon is the same two quads with different corners, so the morph is\n// a straight lerp of eight points instead of a path diff — the triangle's left\n// half straightens into the first bar while its tip unfolds into the second.\n//\n// The play numbers below are recovered from the player's own path data: the\n// corner vertices sit at (7.5, 3) / (7.5, 33) with a 3.93 tangent length, and\n// the tip is a degenerate corner pair at (33, 18).\n\nconst ICON_VIEW_BOX = 36;\nconst EPSILON = 1e-4;\n\ntype Point = readonly [number, number];\ntype Corner = { readonly point: Point; readonly radius: number };\n/** Corners run top-left → bottom-left → bottom-right → top-right. */\ntype Shape = readonly Corner[];\n\nconst PLAY_SHAPES: readonly [Shape, Shape] = [\n  [\n    { point: [7.5, 3], radius: 3.93 },\n    { point: [7.5, 33], radius: 3.93 },\n    { point: [17, 27.4], radius: 0 },\n    { point: [17, 8.6], radius: 0 },\n  ],\n  [\n    { point: [17, 8.6], radius: 0 },\n    { point: [17, 27.4], radius: 0 },\n    { point: [33, 18], radius: 0 },\n    { point: [33, 18], radius: 0 },\n  ],\n];\n\nconst PAUSE_SHAPES: readonly [Shape, Shape] = [\n  [\n    { point: [8.5, 4.5], radius: 2.5 },\n    { point: [8.5, 31.5], radius: 2.5 },\n    { point: [15.5, 31.5], radius: 2.5 },\n    { point: [15.5, 4.5], radius: 2.5 },\n  ],\n  [\n    { point: [20.5, 4.5], radius: 2.5 },\n    { point: [20.5, 31.5], radius: 2.5 },\n    { point: [27.5, 31.5], radius: 2.5 },\n    { point: [27.5, 4.5], radius: 2.5 },\n  ],\n];\n\n// A morph is only a lerp while both icons keep the same corner count.\nif (\n  process.env.NODE_ENV !== \"production\" &&\n  PLAY_SHAPES.some((shape, index) => shape.length !== PAUSE_SHAPES[index].length)\n) {\n  throw new Error(\"play-button: play and pause shapes must share a corner count.\");\n}\n\nfunction lerp(from: number, to: number, progress: number) {\n  return from + (to - from) * progress;\n}\n\nfunction round(value: number) {\n  return Math.round(value * 100) / 100;\n}\n\nfunction coords([x, y]: Point) {\n  return `${round(x)} ${round(y)}`;\n}\n\n/**\n * Offset, as a fraction of the tangent length, that turns a corner fillet into\n * a true circular arc. `interior` is the angle between the two edges meeting at\n * the corner; the player's own control points fall out of this exactly.\n */\nfunction arcControlFraction(interior: number) {\n  return (\n    1 -\n    (4 / 3) * Math.tan((Math.PI - interior) / 4) * Math.tan(interior / 2)\n  );\n}\n\ntype Fillet = {\n  entry: Point;\n  control1: Point;\n  control2: Point;\n  exit: Point;\n};\n\nfunction filletCorner(previous: Point, corner: Corner, next: Point): Fillet {\n  const [vx, vy] = corner.point;\n  const inX = previous[0] - vx;\n  const inY = previous[1] - vy;\n  const outX = next[0] - vx;\n  const outY = next[1] - vy;\n  const inLength = Math.hypot(inX, inY);\n  const outLength = Math.hypot(outX, outY);\n  // Neighbouring fillets must not overlap, and mid-morph an edge can collapse\n  // to nothing (the play tip is two coincident corners), so clamp before use.\n  const tangent = Math.min(corner.radius, inLength / 2, outLength / 2);\n  const degenerate: Fillet = {\n    entry: corner.point,\n    control1: corner.point,\n    control2: corner.point,\n    exit: corner.point,\n  };\n\n  if (tangent <= EPSILON) return degenerate;\n\n  const ux = inX / inLength;\n  const uy = inY / inLength;\n  const wx = outX / outLength;\n  const wy = outY / outLength;\n  const interior = Math.acos(Math.min(1, Math.max(-1, ux * wx + uy * wy)));\n\n  // Folded back on itself, or straight through: no corner left to round.\n  if (interior <= EPSILON || interior >= Math.PI - EPSILON) return degenerate;\n\n  const pull = tangent * arcControlFraction(interior);\n\n  return {\n    entry: [vx + ux * tangent, vy + uy * tangent],\n    control1: [vx + ux * pull, vy + uy * pull],\n    control2: [vx + wx * pull, vy + wy * pull],\n    exit: [vx + wx * tangent, vy + wy * tangent],\n  };\n}\n\nfunction shapeToPath(shape: Shape) {\n  const count = shape.length;\n  const fillets = shape.map((corner, index) =>\n    filletCorner(\n      shape[(index + count - 1) % count].point,\n      corner,\n      shape[(index + 1) % count].point,\n    ),\n  );\n\n  let path = `M ${coords(fillets[count - 1].exit)}`;\n\n  for (const fillet of fillets) {\n    path += ` L ${coords(fillet.entry)} C ${coords(fillet.control1)} ${coords(\n      fillet.control2,\n    )} ${coords(fillet.exit)}`;\n  }\n\n  return `${path} Z`;\n}\n\nfunction lerpShape(from: Shape, to: Shape, progress: number): Shape {\n  return from.map((corner, index) => ({\n    point: [\n      lerp(corner.point[0], to[index].point[0], progress),\n      lerp(corner.point[1], to[index].point[1], progress),\n    ] as Point,\n    radius: lerp(corner.radius, to[index].radius, progress),\n  }));\n}\n\n/** `0` draws the play triangle, `1` draws the pause bars. */\nexport function playPauseIconPath(progress: number) {\n  const clamped = Math.min(1, Math.max(0, progress));\n\n  return PLAY_SHAPES.map((shape, index) =>\n    shapeToPath(lerpShape(shape, PAUSE_SHAPES[index], clamped)),\n  ).join(\" \");\n}\n\n// The glyph is already on screen and changes shape in place, so it eases in and\n// out rather than out-only. 200ms is long enough to read the unfold and short\n// enough to stay under the press.\nconst MORPH_TRANSITION: Transition = {\n  duration: 0.2,\n  ease: [0.65, 0, 0.35, 1],\n};\n\n// The player's bezel: a disc that leaves the button on every toggle. It exits,\n// so it eases out, and it runs past the 300ms UI ceiling on purpose — nothing\n// waits on it, and cutting it short reads as a flicker instead of a pulse.\n//\n// It has to ramp in over the first frames instead of arriving at full strength.\n// On the ghost surface the disc is currentColor against the page, so appearing\n// at peak in a single frame reads as a flash rather than a ripple.\nconst PULSE_PEAK_OPACITY = 0.14;\nconst PULSE_RAMP = 0.08;\n\nconst PULSE_TRANSITION: Transition = {\n  duration: 0.5,\n  ease: [0.16, 1, 0.3, 1],\n  times: [0, PULSE_RAMP, 1],\n};\n\n// Matches the expanding slider's control box: a 24px glyph in a 40px surface.\n// The icon is sized as a ratio so overriding `--play-button-size` scales both.\nconst SURFACE_SIZE = 40;\nconst ICON_SIZE = 24;\nconst ICON_RATIO = `${(ICON_SIZE / SURFACE_SIZE) * 100}%`;\n\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect;\n\nexport type PlayButtonProps = Omit<\n  React.ComponentPropsWithoutRef<\"button\">,\n  \"aria-label\" | \"children\"\n> & {\n  /** Controlled playing state. */\n  playing?: boolean;\n  /** Initial playing state for uncontrolled usage. */\n  defaultPlaying?: boolean;\n  /** Called after the button requests a playing-state change. */\n  onPlayingChange?: (playing: boolean) => void;\n  /** Diameter in px, also published as `--play-button-size` for CSS overrides. */\n  size?: number;\n  /** Accessible name while paused. */\n  playLabel?: string;\n  /** Accessible name while playing. */\n  pauseLabel?: string;\n  /** Opt into the player's bezel: a disc that expands off the button on every toggle. */\n  pulseOnToggle?: boolean;\n  /** `ghost` sits on the page; `frosted` is the player's over-video glass. */\n  surface?: \"ghost\" | \"frosted\";\n};\n\nexport const PlayButton = React.forwardRef<HTMLButtonElement, PlayButtonProps>(\n  function PlayButton(\n    {\n      playing,\n      defaultPlaying = false,\n      onPlayingChange,\n      size = SURFACE_SIZE,\n      playLabel = \"Play\",\n      pauseLabel = \"Pause\",\n      pulseOnToggle = false,\n      surface = \"ghost\",\n      className,\n      style,\n      type = \"button\",\n      disabled,\n      onClick,\n      ...props\n    },\n    ref,\n  ) {\n    const [internalPlaying, setInternalPlaying] = React.useState(defaultPlaying);\n    const [pulseKey, setPulseKey] = React.useState(0);\n    const pathRef = React.useRef<SVGPathElement | null>(null);\n    const shouldReduceMotion = useReducedMotion();\n    const controlled = playing !== undefined;\n    const isPlaying = controlled ? playing : internalPlaying;\n    const progress = useMotionValue(isPlaying ? 1 : 0);\n\n    // React must never re-patch `d`, or a toggle would snap to the end state\n    // for a frame before the animation picked it up. Render the mount-time path\n    // for SSR and hand the attribute to the motion value from then on.\n    const initialPath = React.useRef<string | undefined>(undefined);\n    initialPath.current ??= playPauseIconPath(isPlaying ? 1 : 0);\n\n    useIsomorphicLayoutEffect(\n      () =>\n        progress.on(\"change\", (value) => {\n          pathRef.current?.setAttribute(\"d\", playPauseIconPath(value));\n        }),\n      [progress],\n    );\n\n    useIsomorphicLayoutEffect(() => {\n      const target = isPlaying ? 1 : 0;\n\n      if (shouldReduceMotion) {\n        progress.set(target);\n        return;\n      }\n\n      // Animating the motion value (not a fresh tween) means a double-tap picks\n      // up from wherever the morph currently is instead of jumping.\n      const controls = animate(progress, target, MORPH_TRANSITION);\n\n      return () => controls.stop();\n    }, [isPlaying, progress, shouldReduceMotion]);\n\n    const handleClick = React.useCallback(\n      (event: React.MouseEvent<HTMLButtonElement>) => {\n        onClick?.(event);\n\n        if (event.defaultPrevented || disabled) return;\n\n        const nextPlaying = !isPlaying;\n\n        if (!controlled) {\n          setInternalPlaying(nextPlaying);\n        }\n\n        if (pulseOnToggle) {\n          setPulseKey((key) => key + 1);\n        }\n\n        onPlayingChange?.(nextPlaying);\n      },\n      [\n        controlled,\n        disabled,\n        isPlaying,\n        onClick,\n        onPlayingChange,\n        pulseOnToggle,\n      ],\n    );\n\n    const frosted = surface === \"frosted\";\n\n    return (\n      <button\n        ref={ref}\n        type={type}\n        disabled={disabled}\n        onClick={handleClick}\n        aria-label={isPlaying ? pauseLabel : playLabel}\n        data-slot=\"play-button\"\n        data-state={isPlaying ? \"playing\" : \"paused\"}\n        style={{\n          [\"--play-button-size\" as string]: `${size}px`,\n          width: \"var(--play-button-size)\",\n          height: \"var(--play-button-size)\",\n          ...style,\n        }}\n        className={cn(\n          \"relative inline-grid shrink-0 place-items-center rounded-full outline-none\",\n          // Hover tint and press scale share one short ease-out so the button\n          // answers the pointer before the morph has visibly started.\n          \"transition-[background-color,transform] duration-100 ease-out\",\n          \"active:scale-[0.94] disabled:pointer-events-none disabled:opacity-50\",\n          // currentColor keeps the ring readable on video in either surface.\n          \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current\",\n          \"motion-reduce:transition-none motion-reduce:active:scale-100\",\n          frosted\n            ? \"bg-black/30 text-white backdrop-blur-[16px] hover:bg-black/45\"\n            : \"hover:bg-muted hover:text-foreground dark:hover:bg-muted/50\",\n          className,\n        )}\n        {...props}\n      >\n        {pulseOnToggle && !shouldReduceMotion && pulseKey > 0 ? (\n          <motion.span\n            key={pulseKey}\n            aria-hidden\n            data-slot=\"play-button-pulse\"\n            className=\"pointer-events-none absolute inset-0 rounded-full bg-current\"\n            initial={{ opacity: 0, transform: \"scale(0.9)\" }}\n            animate={{\n              opacity: [0, PULSE_PEAK_OPACITY, 0],\n              transform: [\"scale(0.9)\", \"scale(1.02)\", \"scale(1.7)\"],\n            }}\n            transition={PULSE_TRANSITION}\n          />\n        ) : null}\n        <svg\n          aria-hidden\n          viewBox={`0 0 ${ICON_VIEW_BOX} ${ICON_VIEW_BOX}`}\n          data-slot=\"play-button-icon\"\n          style={{ width: ICON_RATIO, height: ICON_RATIO }}\n          className={cn(\n            \"fill-current\",\n            frosted && \"drop-shadow-[0_0_2px_rgba(0,0,0,0.5)]\",\n          )}\n        >\n          <path ref={pathRef} d={initialPath.current} />\n        </svg>\n      </button>\n    );\n  },\n);\n",
      "type": "registry:ui",
      "target": "components/ui/play-button.tsx"
    }
  ],
  "meta": {
    "tags": [
      "button",
      "toggle",
      "play",
      "pause",
      "media",
      "video",
      "controlled"
    ],
    "effects": [
      "path-morph",
      "svg-animation",
      "pulse",
      "frosted-glass",
      "press-scale",
      "reduced-motion"
    ]
  },
  "categories": [
    "button"
  ],
  "type": "registry:ui"
}