{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "choice-chips",
  "type": "registry:ui",
  "title": "Choice Chips",
  "description": "Filter chips whose check grows the chip open, with rolling counts and clear.",
  "categories": [
    "selection"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/choice-chips.tsx",
      "content": "\"use client\";\nimport { Toggle } from \"@base-ui/react/toggle\";\nimport { ToggleGroup } from \"@base-ui/react/toggle-group\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { createContext, use, useCallback, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Size = \"sm\" | \"md\";\nconst SizeContext = createContext<Size>(\"md\");\n\nexport type ChoiceChipsProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  /** Selected chip values. In single mode it holds zero or one value. */\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n  /** Several chips at once (filters), or at most one (a single choice that can be undone). */\n  multiple?: boolean;\n  /** Show a clear chip at the end of the row whenever anything is selected. */\n  clearable?: boolean;\n  clearLabel?: string;\n  /** Read out after the clear chip empties the selection. */\n  clearedAnnouncement?: string;\n  size?: Size;\n  disabled?: boolean;\n};\n\nexport function ChoiceChips({\n  value: valueProp,\n  defaultValue = [],\n  onValueChange,\n  multiple = true,\n  clearable = true,\n  clearLabel = \"Clear\",\n  clearedAnnouncement = \"Selection cleared\",\n  size = \"md\",\n  disabled = false,\n  className,\n  children,\n  ref,\n  ...rest\n}: ChoiceChipsProps) {\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const [announcement, setAnnouncement] = useState(\"\");\n  const groupRef = useRef<HTMLDivElement>(null);\n  // Keep our handle on the group (for focus after Clear) while still forwarding the caller's ref.\n  const setGroupRef = useCallback(\n    (node: HTMLDivElement | null) => {\n      groupRef.current = node;\n      if (typeof ref === \"function\") return ref(node);\n      if (ref) ref.current = node;\n    },\n    [ref],\n  );\n  const reduce = useReducedMotion();\n  const showClear = clearable && value.length > 0 && !disabled;\n\n  const clear = useCallback(() => {\n    setValue([]);\n    setAnnouncement(clearedAnnouncement);\n    // The clear chip is about to unmount under the focus; hand focus to the first chip\n    // so the keyboard user stays inside the group instead of falling back to <body>.\n    groupRef.current?.querySelector<HTMLElement>(\"[data-chip]:not([data-disabled])\")?.focus();\n  }, [setValue, clearedAnnouncement]);\n\n  return (\n    <SizeContext value={size}>\n      <ToggleGroup\n        ref={setGroupRef}\n        multiple={multiple}\n        value={value}\n        onValueChange={(next) => {\n          setValue(next);\n          setAnnouncement(\"\");\n        }}\n        disabled={disabled}\n        data-size={size}\n        className={cn(\"flex min-w-0 flex-wrap items-center\", size === \"sm\" ? \"gap-1.5\" : \"gap-2\", className)}\n        {...rest}\n      >\n        {children}\n        <AnimatePresence initial={false}>\n          {showClear && (\n            <motion.button\n              key=\"clear\"\n              type=\"button\"\n              onClick={clear}\n              data-size={size}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.9, filter: \"blur(2px)\", transition: { duration: 0.12, ease: ease.in } }}\n              transition={reduce ? { duration: 0.15 } : spring.pop}\n              className={cn(\n                \"relative inline-flex shrink-0 select-none items-center rounded-full font-medium text-fg-3\",\n                \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.96] active:duration-75\",\n                \"after:absolute after:-inset-y-1.5 after:inset-x-0 after:content-['']\",\n                size === \"sm\" ? \"h-7 gap-1 pl-1.5 pr-2 text-[12px]\" : \"h-8 gap-1.5 pl-2 pr-2.5 text-[12.5px]\",\n              )}\n            >\n              <X size={size === \"sm\" ? 12 : 14} />\n              {clearLabel}\n            </motion.button>\n          )}\n        </AnimatePresence>\n      </ToggleGroup>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </SizeContext>\n  );\n}\n\nexport type ChoiceChipProps = Omit<React.ComponentProps<\"button\">, \"value\" | \"type\"> & {\n  value: string;\n  /** A leading icon. It moves right when the check grows in. */\n  icon?: React.ReactNode;\n  /** A count shown after the label. Changes roll digit by digit. */\n  count?: number;\n  disabled?: boolean;\n};\n\nexport function ChoiceChip({ value, icon, count, disabled, className, children, ...rest }: ChoiceChipProps) {\n  const size = use(SizeContext);\n  const sm = size === \"sm\";\n  return (\n    <Toggle\n      value={value}\n      disabled={disabled}\n      data-chip=\"\"\n      data-size={size}\n      className={cn(\n        \"group/chip relative inline-flex min-w-0 max-w-full shrink-0 select-none items-center rounded-full border font-medium tracking-[-0.005em]\",\n        \"outline-none 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,padding] duration-200 ease-out-expo active:scale-[0.96] active:duration-75\",\n        \"border-line-2 bg-raised text-fg-2 hover:border-fg-4 hover:text-fg\",\n        \"data-pressed:border-fg data-pressed:bg-fg data-pressed:text-frame data-pressed:hover:border-fg/90 data-pressed:hover:bg-fg/90\",\n        \"data-disabled:pointer-events-none data-disabled:opacity-50\",\n        // Reach 44px on touch without changing the drawn size.\n        \"after:absolute after:-inset-y-1.5 after:inset-x-0 after:content-['']\",\n        \"motion-reduce:transition-colors\",\n        sm ? \"h-7 pl-2.5 pr-2.5 text-[12px] data-pressed:pl-2\" : \"h-8 pl-3 pr-3 text-[12.5px] data-pressed:pl-2.5\",\n        className,\n      )}\n      {...rest}\n    >\n      {/* The check lives in a grid track that goes from 0fr to 1fr, so the chip's\n          width grows to fit it on a transition that reverses mid-way if pressed again. */}\n      <span\n        aria-hidden\n        className={cn(\n          \"grid grid-cols-[0fr] transition-[grid-template-columns] duration-[260ms] ease-out-expo group-data-pressed/chip:grid-cols-[1fr] motion-reduce:transition-none\",\n        )}\n      >\n        <span className=\"min-w-0 overflow-hidden\">\n          <span className={cn(\"flex items-center\", sm ? \"w-[17px]\" : \"w-5\")}>\n            <svg width={sm ? 12 : 14} height={sm ? 12 : 14} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n              <path\n                d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                pathLength={1}\n                strokeDasharray=\"1\"\n                className=\"[stroke-dashoffset:1] transition-[stroke-dashoffset] duration-100 ease-out group-data-pressed/chip:[stroke-dashoffset:0] group-data-pressed/chip:delay-75 group-data-pressed/chip:duration-300 motion-reduce:transition-none\"\n              />\n            </svg>\n          </span>\n        </span>\n      </span>\n      {icon && (\n        <span aria-hidden className={cn(\"flex shrink-0 items-center text-fg-3 transition-colors group-hover/chip:text-fg-2 group-data-pressed/chip:text-frame/70 [&>svg]:size-[14px]\", sm ? \"mr-1 [&>svg]:size-3\" : \"mr-1.5\")}>\n          {icon}\n        </span>\n      )}\n      <span className=\"min-w-0 truncate\">{children}</span>\n      {count !== undefined && (\n        <span className={cn(\"tabular shrink-0 font-normal text-fg-3 transition-colors group-data-pressed/chip:text-frame/60\", sm ? \"ml-1\" : \"ml-1.5\")}>\n          <NumberFlow value={count} />\n        </span>\n      )}\n    </Toggle>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/choice-chips"
}
