{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"toggle-group","type":"registry:ui","title":"Toggle group","description":"A row of icon or text toggles, single or multiple: one pressed fill glides between options, or each fades in on its own and neighbours join, as in a formatting toolbar.","categories":["inputs"],"dependencies":["motion"],"registryDependencies":["https://uiarc.dev/r/arc-motion-tokens.json"],"files":[{"path":"registry/components/toggle-group/toggle-group.tsx","type":"registry:component","target":"@components/arc/toggle-group/toggle-group.tsx","content":"\"use client\";\n\nimport { createContext, useContext, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ButtonHTMLAttributes, KeyboardEvent, ReactNode } from \"react\";\nimport { LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./toggle-group.module.css\";\n\nexport type ToggleGroupSize = \"sm\" | \"md\";\n\ninterface ToggleGroupBase {\n  children: ReactNode;\n  /** Accessible name of the group, such as \"Text style\" or \"Alignment\". */\n  label: string;\n  size?: ToggleGroupSize;\n  disabled?: boolean;\n  className?: string;\n}\n\nexport interface ToggleGroupSingleProps extends ToggleGroupBase {\n  /** One option at most is pressed. Choosing another moves the pressed state across; the pressed option stays pressed. */\n  type?: \"single\";\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (value: string) => void;\n}\n\nexport interface ToggleGroupMultipleProps extends ToggleGroupBase {\n  /** Each option toggles on its own. */\n  type: \"multiple\";\n  value?: readonly string[];\n  defaultValue?: readonly string[];\n  onValueChange?: (value: string[]) => void;\n}\n\nexport type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps;\n\nexport interface ToggleGroupItemProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"value\" | \"type\"> {\n  value: string;\n  /** Accessible name, required when the item shows only an icon. Also used as the tooltip title unless title is set. */\n  label?: string;\n  children: ReactNode;\n}\n\ninterface GroupContext {\n  type: \"single\" | \"multiple\";\n  id: string;\n  pressed: readonly string[];\n  toggle: (value: string) => void;\n  tabStop: string | null;\n  setFocused: (value: string) => void;\n  disabled: boolean;\n  reduced: boolean;\n}\n\nconst Context = createContext<GroupContext | null>(null);\n\nconst items = (root: HTMLElement | null) => Array.from(root?.querySelectorAll<HTMLButtonElement>(\"[data-toggle-item]:not(:disabled)\") ?? []);\n\n/**\n * A row of toggle buttons. In single mode one pressed fill glides from option to option; in multiple mode each fill fades in\n * on its own and neighbours that are both on join into one shape. Arrow keys move focus, Space and Enter toggle.\n */\nexport function ToggleGroup(props: ToggleGroupProps) {\n  const { children, label, size = \"md\", disabled = false, className } = props;\n  const type = props.type ?? \"single\";\n  const id = useId();\n  const reduced = useReducedMotion() ?? false;\n  const root = useRef<HTMLDivElement>(null);\n\n  const [inner, setInner] = useState<readonly string[]>(() => {\n    if (props.type === \"multiple\") return props.defaultValue ?? [];\n    return props.defaultValue ? [props.defaultValue] : [];\n  });\n  const controlled = props.value !== undefined;\n  const pressed: readonly string[] = props.type === \"multiple\" ? (props.value ?? inner) : controlled ? (props.value ? [props.value] : []) : inner;\n\n  const toggle = (value: string) => {\n    if (props.type === \"multiple\") {\n      const next = pressed.includes(value) ? pressed.filter(entry => entry !== value) : [...pressed, value];\n      if (!controlled) setInner(next);\n      props.onValueChange?.(next);\n      return;\n    }\n    // Single choice behaves like a radio group: choosing the pressed option again keeps it.\n    if (pressed[0] === value) return;\n    if (!controlled) setInner([value]);\n    (props as ToggleGroupSingleProps).onValueChange?.(value);\n  };\n\n  // Roving tab stop: the last focused item, else the first pressed one, else the first item. Item order is read after each render.\n  const [focused, setFocused] = useState<string | null>(null);\n  const [order, setOrder] = useState<readonly string[]>([]);\n  useLayoutEffect(() => {\n    const next = items(root.current).map(item => item.dataset.value ?? \"\");\n    setOrder(current => current.join(\"\\u0000\") === next.join(\"\\u0000\") ? current : next);\n  }, [children]);\n  const tabStop = (focused && order.includes(focused) ? focused : pressed.find(value => order.includes(value))) ?? order[0] ?? null;\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const list = items(root.current);\n    const index = list.findIndex(item => item === document.activeElement);\n    if (index < 0) return;\n    const last = list.length - 1;\n    const target = event.key === \"ArrowRight\" || event.key === \"ArrowDown\" ? (index === last ? 0 : index + 1)\n      : event.key === \"ArrowLeft\" || event.key === \"ArrowUp\" ? (index === 0 ? last : index - 1)\n        : event.key === \"Home\" ? 0 : event.key === \"End\" ? last : -1;\n    if (target < 0) return;\n    event.preventDefault();\n    list[target]!.focus();\n  };\n\n  const classes = [styles.group, styles[size], className].filter(Boolean).join(\" \");\n  return <Context.Provider value={{ type, id, pressed, toggle, tabStop, setFocused, disabled, reduced }}>\n    <div ref={root} className={classes} role={type === \"single\" ? \"radiogroup\" : \"group\"} aria-label={label} aria-disabled={disabled || undefined} data-type={type} onKeyDown={onKeyDown}>\n      <LayoutGroup id={id}>{children}</LayoutGroup>\n    </div>\n  </Context.Provider>;\n}\n\nexport function ToggleGroupItem({ value, label, children, disabled: itemDisabled, className, title, onClick, onFocus, ...rest }: ToggleGroupItemProps) {\n  const group = useContext(Context);\n  if (!group) throw new Error(\"ToggleGroupItem must be used inside a ToggleGroup.\");\n  const { type, pressed, toggle, tabStop, setFocused, disabled: groupDisabled, reduced } = group;\n  const on = pressed.includes(value);\n  const disabled = groupDisabled || itemDisabled;\n  const single = type === \"single\";\n  return <button\n    {...rest}\n    type=\"button\"\n    className={[styles.item, className].filter(Boolean).join(\" \")}\n    data-toggle-item=\"\"\n    data-value={value}\n    data-state={on ? \"on\" : \"off\"}\n    role={single ? \"radio\" : undefined}\n    aria-checked={single ? on : undefined}\n    aria-pressed={single ? undefined : on}\n    aria-label={label}\n    title={title ?? label}\n    disabled={disabled}\n    tabIndex={tabStop === value ? 0 : -1}\n    onFocus={event => { setFocused(value); onFocus?.(event); }}\n    onClick={event => { onClick?.(event); if (!event.defaultPrevented) toggle(value); }}\n  >\n    {single\n      ? on && <motion.span className={styles.fill} layoutId=\"fill\" initial={false} style={{ borderRadius: 15 }} transition={reduced ? { duration: 0 } : motionTokens.spring.morph} aria-hidden=\"true\" />\n      : <span className={styles.fill} aria-hidden=\"true\" />}\n    <span className={styles.content}>{children}</span>\n  </button>;\n}\n\nToggleGroup.Item = ToggleGroupItem;\n\nexport default ToggleGroup;\n"},{"path":"registry/components/toggle-group/toggle-group.module.css","type":"registry:component","target":"@components/arc/toggle-group/toggle-group.module.css","content":"/* A flat row on the surface. Pressed options sink into a tinted fill, unlike a segmented control's raised pill on a sunken track. */\n.group { --tg-h: 36px; --tg-gap: 2px; --tg-pad: 3px; --tg-radius: calc(var(--radius-control) - var(--tg-pad)); --tg-fill: color-mix(in oklab, var(--control-on) 11%, var(--surface)); isolation: isolate; display: inline-flex; max-width: 100%; align-items: center; gap: var(--tg-gap); padding: var(--tg-pad); border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); }\n.sm { --tg-h: 28px; }\n.md { --tg-h: 36px; }\n.group[aria-disabled=\"true\"] { opacity: .52; }\n:global(:root[data-theme=\"dark\"]) .group { --tg-fill: color-mix(in oklab, var(--control-on) 14%, var(--surface)); }\n\n.item { position: relative; display: inline-flex; min-width: var(--tg-h); height: var(--tg-h); flex: 0 0 auto; align-items: center; justify-content: center; border: 0; border-radius: var(--tg-radius); padding: 0 10px; background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: 1; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }\n.sm .item { padding: 0 8px; font-size: var(--text-xs); }\n.item:disabled { cursor: not-allowed; opacity: .45; }\n@media (hover: hover) and (pointer: fine) {\n  .item:hover:not(:disabled) { color: var(--foreground); }\n  .item[data-state=\"off\"]:hover:not(:disabled) { background: var(--surface-muted); }\n}\n.item[data-state=\"on\"] { color: var(--foreground); }\n.item:focus-visible { color: var(--foreground); background: var(--surface-muted); }\n.item[data-state=\"on\"]:focus-visible { background: transparent; }\n\n/* Label and icon give a small press; the item box itself never scales, so neighbours stay put. */\n.content { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 6px; transition: transform var(--duration-spring) var(--ease-spring); }\n.content svg { flex: none; width: 16px; height: 16px; stroke-width: 1.75; }\n.sm .content svg { width: 14px; height: 14px; }\n.item:active:not(:disabled) .content { transform: scale(.96); transition-duration: var(--duration-fast); transition-timing-function: var(--ease-standard); }\n\n/* Single: Motion moves one fill between options. Multiple: every option owns a fill that fades and settles in place. */\n.fill { position: absolute; z-index: 0; inset: 0; border-radius: var(--tg-radius); background: var(--tg-fill); pointer-events: none; }\n.group[data-type=\"multiple\"] .fill { opacity: 0; transform: scale(.86); transition: opacity var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring), right var(--duration-standard) var(--ease-standard), left var(--duration-standard) var(--ease-standard), border-radius var(--duration-standard) var(--ease-standard); }\n.group[data-type=\"multiple\"] .item[data-state=\"on\"] .fill { opacity: 1; transform: none; }\n/* Neighbours that are both on join into one shape across the gap. */\n.group[data-type=\"multiple\"] .item[data-state=\"on\"]:has(+ .item[data-state=\"on\"]) .fill { right: calc(var(--tg-gap) * -1); border-top-right-radius: 0; border-bottom-right-radius: 0; }\n.group[data-type=\"multiple\"] .item[data-state=\"on\"] + .item[data-state=\"on\"] .fill { border-top-left-radius: 0; border-bottom-left-radius: 0; }\n\n@media (prefers-reduced-motion: reduce) {\n  .item, .content, .group[data-type=\"multiple\"] .fill { transition: opacity var(--duration-instant) var(--ease-standard), color var(--duration-instant) var(--ease-standard), background-color var(--duration-instant) var(--ease-standard); }\n  .group[data-type=\"multiple\"] .fill, .item:active:not(:disabled) .content { transform: none; }\n}\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/toggle-group","meta":{"tier":"free","kind":"component","tags":["input","toggle","toolbar","new"],"docs":"https://uiarc.dev/components/toggle-group","markdown":"https://uiarc.dev/components/toggle-group/markdown"}}