{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toggle-button",
  "type": "registry:ui",
  "title": "Toggle Button",
  "description": "Stays on with aria-pressed, and its icon morphs: fills, pins, mutes.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json",
    "https://www.stealth.pm/r/icon-button.json"
  ],
  "files": [
    {
      "path": "components/ui/toggle-button.tsx",
      "content": "\"use client\";\nimport { Toggle } from \"@base-ui/react/toggle\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { createContext, useContext, useId } from \"react\";\nimport { ActionTooltip, type TooltipSide } from \"@/components/ui/icon-button\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring, swap } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nconst PressedContext = createContext(false);\n/** Whether the surrounding ToggleButton is on. For drawing your own glyph that reacts. */\nexport const useTogglePressed = () => useContext(PressedContext);\n\nexport type ToggleButtonProps = Omit<React.ComponentProps<\"button\">, \"value\" | \"defaultValue\" | \"onChange\" | \"className\"> & {\n  className?: string;\n  pressed?: boolean;\n  defaultPressed?: boolean;\n  onPressedChange?: (pressed: boolean) => void;\n  /**\n   * Makes it an icon-only square: the label becomes its accessible name and its\n   * tooltip. Leave it out to show children (an icon and a word) as the label.\n   */\n  label?: string;\n  /** Tooltip text when it should say less than the label; false for none. */\n  tooltip?: boolean | React.ReactNode;\n  shortcut?: string;\n  tooltipSide?: TooltipSide;\n  size?: \"sm\" | \"md\" | \"lg\";\n  variant?: \"ghost\" | \"secondary\";\n  children: React.ReactNode;\n};\n\nconst box = {\n  sm: { square: \"size-7 rounded-md\", wide: \"h-7 gap-1.5 rounded-md px-2 has-[svg]:pl-1.5\", text: \"text-[12px] [&_svg]:size-3.5\" },\n  md: { square: \"size-8 rounded-lg\", wide: \"h-8 gap-1.5 rounded-lg px-2.5 has-[svg]:pl-2\", text: \"text-[12.5px] [&_svg]:size-4\" },\n  lg: { square: \"size-9 rounded-lg\", wide: \"h-9 gap-2 rounded-lg px-3 has-[svg]:pl-2.5\", text: \"text-[13px] [&_svg]:size-4\" },\n};\n\nconst looks = {\n  ghost: \"text-fg-2 hover:bg-hover hover:text-fg data-pressed:bg-fg/[0.08] data-pressed:text-fg\",\n  secondary:\n    \"border border-line-2 bg-raised text-fg-2 shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover hover:text-fg data-pressed:border-fg-4 data-pressed:bg-hover data-pressed:text-fg\",\n};\n\n/**\n * A button that stays on: bold, pin, bookmark, mute. The label never changes with\n * the state (aria-pressed says it); the icon does, with a small morph of its own.\n */\nexport function ToggleButton({\n  pressed: pressedProp,\n  defaultPressed = false,\n  onPressedChange,\n  label,\n  tooltip = true,\n  shortcut,\n  tooltipSide = \"top\",\n  size = \"md\",\n  variant = \"ghost\",\n  disabled,\n  className,\n  children,\n  ...rest\n}: ToggleButtonProps) {\n  const [pressed, setPressed] = useControllableState({ value: pressedProp, defaultValue: defaultPressed, onChange: onPressedChange });\n  const iconOnly = label !== undefined;\n  const s = box[size];\n\n  const toggle = (\n    <Toggle\n      pressed={pressed}\n      onPressedChange={(next) => setPressed(next)}\n      disabled={disabled}\n      aria-label={label}\n      data-size={size}\n      data-variant={variant}\n      className={cn(\n        \"relative inline-flex shrink-0 select-none items-center justify-center font-medium tracking-[-0.005em] outline-none\",\n        \"touch-manipulation [-webkit-tap-highlight-color:transparent]\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"transition-[background-color,border-color,color,scale] duration-150 ease-out-quart active:duration-75\",\n        iconOnly ? \"active:scale-[0.93]\" : \"active:scale-[0.97]\",\n        \"data-disabled:pointer-events-none data-disabled:opacity-50\",\n        iconOnly && \"pointer-coarse:after:absolute pointer-coarse:after:left-1/2 pointer-coarse:after:top-1/2 pointer-coarse:after:size-11 pointer-coarse:after:-translate-1/2\",\n        iconOnly ? s.square : s.wide,\n        s.text,\n        looks[variant],\n        className,\n      )}\n      {...(rest as Toggle.Props)}\n    >\n      <PressedContext.Provider value={pressed}>{children}</PressedContext.Provider>\n    </Toggle>\n  );\n\n  if (!iconOnly || tooltip === false || tooltip == null) return toggle;\n  return (\n    <ActionTooltip content={tooltip === true ? label : tooltip} shortcut={shortcut} side={tooltipSide} disabled={disabled}>\n      {toggle}\n    </ActionTooltip>\n  );\n}\n\n// ─── Glyphs ─────────────────────────────────────────────────────────────────\n// Each reads the pressed state from the button around it. Same 16px grid and\n// 1.4 stroke as the rest of the icon set.\n\nconst svg = {\n  width: 16,\n  height: 16,\n  viewBox: \"0 0 16 16\",\n  fill: \"none\",\n  stroke: \"currentColor\",\n  strokeWidth: 1.4,\n  strokeLinecap: \"round\" as const,\n  strokeLinejoin: \"round\" as const,\n  \"aria-hidden\": true,\n  focusable: false,\n};\n\n/** Pops on the way on, dips on the way off. Resting values are the same with or without reduced motion. */\nfunction usePop(on: boolean, amount = 1.2) {\n  const reduce = useReducedMotion();\n  if (reduce) return { animate: { scale: 1, rotate: 0 }, transition: { duration: 0 } };\n  return on\n    ? { animate: { scale: [1, amount, 1] }, transition: { duration: 0.34, times: [0, 0.35, 1], ease: ease.out } }\n    : { animate: { scale: [1, 0.86, 1] }, transition: { duration: 0.22, times: [0, 0.4, 1], ease: ease.out } };\n}\n\n/** Outline to filled: the fill drops in from the top like a ribbon, then the mark pops. */\nexport function BookmarkGlyph() {\n  const on = useTogglePressed();\n  const reduce = useReducedMotion();\n  const id = useId();\n  const d = \"M4.25 2.75h7.5v10.5L8 10.75l-3.75 2.5z\";\n  return (\n    <motion.svg {...svg} initial={false} {...usePop(on)}>\n      <clipPath id={`${id}-clip`}>\n        <motion.rect\n          x=\"0\"\n          y=\"0\"\n          width=\"16\"\n          initial={false}\n          animate={{ height: on ? 16 : 0 }}\n          transition={reduce ? { duration: 0 } : { duration: on ? 0.26 : 0.16, ease: ease.out }}\n        />\n      </clipPath>\n      <path d={d} fill=\"currentColor\" stroke=\"none\" clipPath={`url(#${id}-clip)`} />\n      <path d={d} />\n    </motion.svg>\n  );\n}\n\n/** The fill springs out from the center while the star gives a small twist. */\nexport function StarGlyph() {\n  const on = useTogglePressed();\n  const reduce = useReducedMotion();\n  const d = \"m8 2.25 1.75 3.6 3.95.55-2.85 2.8.7 3.95L8 11.3l-3.55 1.85.7-3.95L2.3 6.4l3.95-.55z\";\n  const pop = usePop(on, 1.18);\n  const twist = !reduce && on ? { rotate: [0, -16, 0] } : { rotate: 0 };\n  return (\n    <motion.svg {...svg} initial={false} animate={{ ...pop.animate, ...twist }} transition={pop.transition}>\n      <motion.path\n        d={d}\n        fill=\"currentColor\"\n        stroke=\"none\"\n        style={{ transformBox: \"fill-box\", transformOrigin: \"50% 55%\" }}\n        initial={false}\n        animate={{ scale: on ? 1 : 0.3, opacity: on ? 1 : 0 }}\n        transition={reduce ? { duration: 0.12 } : on ? spring.bouncy : { duration: 0.14, ease: ease.out }}\n      />\n      <path d={d} />\n    </motion.svg>\n  );\n}\n\n/** Fills from the center on a spring with a single beat. */\nexport function HeartGlyph() {\n  const on = useTogglePressed();\n  const reduce = useReducedMotion();\n  const d = \"M8 13.25S2.25 10 2.25 6.1A2.85 2.85 0 0 1 8 4.9a2.85 2.85 0 0 1 5.75 1.2C13.75 10 8 13.25 8 13.25z\";\n  return (\n    <motion.svg {...svg} initial={false} {...usePop(on, 1.26)}>\n      <motion.path\n        d={d}\n        fill=\"currentColor\"\n        stroke=\"none\"\n        style={{ transformBox: \"fill-box\", transformOrigin: \"50% 50%\" }}\n        initial={false}\n        animate={{ scale: on ? 1 : 0.4, opacity: on ? 1 : 0 }}\n        transition={reduce ? { duration: 0.12 } : on ? spring.bouncy : { duration: 0.14, ease: ease.out }}\n      />\n      <path d={d} />\n    </motion.svg>\n  );\n}\n\n/** Tilted and hollow when loose; swings upright and fills when pinned. */\nexport function PinGlyph() {\n  const on = useTogglePressed();\n  const reduce = useReducedMotion();\n  const body = \"M6.25 2.75h3.5v3.1l2 2.65h-7.5l2-2.65z\";\n  return (\n    <svg {...svg}>\n      <motion.g\n        style={{ transformBox: \"view-box\", transformOrigin: \"8px 8px\" }}\n        initial={false}\n        animate={{ rotate: on ? 0 : 40, y: on ? 0.5 : 0 }}\n        transition={reduce ? { duration: 0 } : spring.snappy}\n      >\n        <motion.path\n          d={body}\n          fill=\"currentColor\"\n          stroke=\"none\"\n          initial={false}\n          animate={{ opacity: on ? 1 : 0 }}\n          transition={{ duration: reduce ? 0.12 : 0.18, ease: ease.out }}\n        />\n        <path d={body} />\n        <path d=\"M8 8.5v4.75\" />\n      </motion.g>\n    </svg>\n  );\n}\n\n/** Pressed means muted: the waves retract into the speaker and a cross draws in. */\nexport function MuteGlyph() {\n  const on = useTogglePressed();\n  const reduce = useReducedMotion();\n  const wave = (delay: number) => ({\n    initial: false as const,\n    animate: { pathLength: on ? 0 : 1, opacity: on ? 0 : 1 },\n    transition: reduce ? { duration: 0.12 } : { duration: on ? 0.16 : 0.26, delay: on ? 0 : delay, ease: ease.out },\n  });\n  const cross = (delay: number) => ({\n    initial: false as const,\n    animate: { pathLength: on ? 1 : 0, opacity: on ? 1 : 0 },\n    transition: reduce ? { duration: 0.12 } : { duration: on ? 0.22 : 0.12, delay: on ? delay : 0, ease: ease.out },\n  });\n  return (\n    <svg {...svg}>\n      <path d=\"M7.5 3.25 4.6 5.6H2.6v4.8h2l2.9 2.35z\" />\n      <motion.path d=\"M10 6.2a2.6 2.6 0 0 1 0 3.6\" {...wave(0.04)} />\n      <motion.path d=\"M12 4.4a5.2 5.2 0 0 1 0 7.2\" {...wave(0.1)} />\n      <motion.path d=\"m10.25 6.4 3.2 3.2\" {...cross(0.08)} />\n      <motion.path d=\"m13.45 6.4-3.2 3.2\" {...cross(0.16)} />\n    </svg>\n  );\n}\n\n/** Any two icons, swapped in place with a small pop. */\nexport function ToggleIcon({ off, on: onIcon }: { off: React.ReactNode; on: React.ReactNode }) {\n  const on = useTogglePressed();\n  const reduce = useReducedMotion();\n  return (\n    <span aria-hidden className=\"grid size-4 place-items-center [&>*]:col-start-1 [&>*]:row-start-1\">\n      <AnimatePresence initial={false}>\n        <motion.span\n          key={on ? \"on\" : \"off\"}\n          className=\"grid place-items-center\"\n          initial={reduce ? { opacity: 0 } : swap.initial}\n          animate={swap.animate}\n          exit={reduce ? { opacity: 0 } : swap.exit}\n          transition={reduce ? { duration: 0.12 } : spring.pop}\n        >\n          {on ? onIcon : off}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/toggle-button"
}
