{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"toggle-group","type":"registry:component","title":"Toggle group","description":"On/off choices side by side whose fills merge: two pressed toggles next to each other square off their inner corners and read as one shape.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/toggle-group.tsx","type":"registry:component","content":"// Hairline UI · toggle-group · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { EASE_OUT, INTERACTION_FOCUS, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * TOGGLE GROUP: several on/off choices side by side\n *\n *   press    each toggle fills when it's on and empties when off\n *   merge    toggles that are on next to each other join into one\n *            shape: the corners between them square off, so Bold +\n *            Italic read as one pill instead of two blobs\n *   single   <Toggle> on its own is one pressed/unpressed button\n *\n * For one choice out of several, use the segmented control.\n * ───────────────────────────────────────────────────────── */\n\nexport interface ToggleOption {\n  value: string;\n  label: string;\n  icon?: React.ReactNode;\n  /** Show only the icon; `label` becomes the accessible name. */\n  iconOnly?: boolean;\n  disabled?: boolean;\n}\n\nexport interface ToggleGroupProps {\n  options: ToggleOption[];\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n  /** Accessible name of the group. */\n  label: string;\n  size?: \"sm\" | \"md\";\n  className?: string;\n}\n\nconst R = 999;\n\nexport function ToggleGroup({ options, value, defaultValue = [], onValueChange, label, size = \"md\", className = \"\" }: ToggleGroupProps) {\n  const reduced = useInteractionReducedMotion();\n  const [own, setOwn] = React.useState(defaultValue);\n  const on = value ?? own;\n  const sm = size === \"sm\";\n\n  const flip = (v: string) => {\n    const next = on.includes(v) ? on.filter((x) => x !== v) : [...on, v];\n    if (value === undefined) setOwn(next);\n    onValueChange?.(next);\n  };\n\n  return (\n    <div role=\"group\" aria-label={label} className={`inline-flex items-center rounded-full bg-card p-0.5 shadow-[inset_0_0_0_1px_var(--border)] ${className}`}>\n      {options.map((o, i) => {\n        const pressed = on.includes(o.value);\n        const left = pressed && on.includes(options[i - 1]?.value ?? \"\");\n        const right = pressed && on.includes(options[i + 1]?.value ?? \"\");\n        return (\n          <button\n            key={o.value}\n            type=\"button\"\n            aria-pressed={pressed}\n            aria-label={o.iconOnly ? o.label : undefined}\n            disabled={o.disabled}\n            onClick={() => flip(o.value)}\n            className={`group/tg relative inline-flex shrink-0 items-center justify-center gap-1.5 font-medium cursor-pointer disabled:cursor-default disabled:opacity-40 [&_svg]:size-3.5 ${\n              sm ? \"h-6 text-[12px]\" : \"h-8 text-[13px]\"\n            } ${o.iconOnly ? (sm ? \"w-6\" : \"w-8\") : sm ? \"px-2.5\" : \"px-3\"} rounded-full ${pressed ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\"} ${INTERACTION_FOCUS}`}\n          >\n            {/* The fill: corners next to another pressed toggle square off so they read as one. */}\n            <span\n              aria-hidden=\"true\"\n              className=\"absolute inset-0 bg-[color-mix(in_oklab,var(--foreground)_9%,transparent)] transition-[scale] duration-200 group-active/tg:scale-[0.96] motion-reduce:group-active/tg:scale-100\"\n              style={{\n                opacity: pressed ? 1 : 0,\n                borderTopLeftRadius: left ? 4 : R,\n                borderBottomLeftRadius: left ? 4 : R,\n                borderTopRightRadius: right ? 4 : R,\n                borderBottomRightRadius: right ? 4 : R,\n                transition: reduced ? \"none\" : `opacity 220ms ${EASE_OUT}, border-radius 320ms ${EASE_OUT}, scale 200ms ${EASE_OUT}`,\n              }}\n            />\n            <span className=\"relative inline-flex items-center gap-1.5\">\n              {o.icon}\n              {!o.iconOnly && o.label}\n            </span>\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\nexport interface ToggleProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"onChange\"> {\n  pressed?: boolean;\n  defaultPressed?: boolean;\n  onPressedChange?: (pressed: boolean) => void;\n  size?: \"sm\" | \"md\";\n}\n\n/** One pressed/unpressed button. Icon-only toggles need an aria-label. */\nexport function Toggle({ pressed: controlled, defaultPressed = false, onPressedChange, size = \"md\", className = \"\", children, onClick, ...props }: ToggleProps) {\n  const reduced = useInteractionReducedMotion();\n  const [own, setOwn] = React.useState(defaultPressed);\n  const pressed = controlled ?? own;\n  return (\n    <button\n      type=\"button\"\n      aria-pressed={pressed}\n      onClick={(e) => {\n        onClick?.(e);\n        if (e.defaultPrevented) return;\n        if (controlled === undefined) setOwn(!pressed);\n        onPressedChange?.(!pressed);\n      }}\n      className={`group/tg relative inline-flex shrink-0 items-center justify-center gap-1.5 rounded-full font-medium cursor-pointer disabled:cursor-default disabled:opacity-40 [&_svg]:size-3.5 ${\n        size === \"sm\" ? \"h-7 min-w-7 px-2 text-[12px]\" : \"h-9 min-w-9 px-2.5 text-[13px]\"\n      } ${pressed ? \"text-foreground\" : \"text-muted-foreground hover:bg-accent/50 hover:text-foreground\"} ${INTERACTION_FOCUS} ${className}`}\n      {...props}\n    >\n      <span\n        aria-hidden=\"true\"\n        className=\"absolute inset-0 rounded-full bg-accent shadow-[inset_0_0_0_1px_var(--border)] transition-[scale] duration-200 group-active/tg:scale-[0.94] motion-reduce:group-active/tg:scale-100\"\n        style={{ opacity: pressed ? 1 : 0, transition: reduced ? \"none\" : `opacity 220ms ${EASE_OUT}, scale 200ms ${EASE_OUT}` }}\n      />\n      <span className=\"relative inline-flex items-center gap-1.5\">{children}</span>\n    </button>\n  );\n}\n\nexport default ToggleGroup;\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/toggle-group. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}