{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "select",
  "type": "registry:ui",
  "title": "Select",
  "description": "An accessible React select with a gliding highlight, groups, labels and a scrollable list, built on Radix Select.",
  "author": "FewTone <https://fewtone.com>",
  "dependencies": [
    "lucide-react@^1.52.0",
    "motion@^14.0.0",
    "radix-ui@^1.7.0"
  ],
  "registryDependencies": [
    "https://ui.fewtone.com/r/tokens.json",
    "https://ui.fewtone.com/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/fewtone/ui/select.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { spring } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { CheckIcon, ChevronDownIcon, SearchIcon } from \"lucide-react\";\nimport { LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { Select as SelectPrimitive } from \"radix-ui\";\nimport {\n  type ComponentProps,\n  createContext,\n  isValidElement,\n  type KeyboardEvent,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\n\n/*\n * Select, built on Radix Select (a select-only combobox: arrow keys, Home/End, typeahead, Escape).\n * - Opening and closing are CSS keyframes from the trigger's side.\n * - The highlight is a pill that glides between options. Radix moves focus on hover and on arrow keys, so\n *   the pill follows both.\n * - Long lists scroll natively, capped at 18rem. Soft fades at the ends show there's more; they replace\n *   Radix's scroll arrows (owner, 2026-10-14).\n * - `searchable` adds a search field above the list (owner, 2026-10-14):\n *   - Typing filters the options. Hidden options stay mounted, so the trigger keeps showing the selected value.\n *   - The keyboard stays in the field: arrow keys move the pill through the matches, and Enter picks the\n *     highlighted one.\n *   - Typing after hovering an option goes back to the field.\n *   - The popup becomes a dialog holding the field and a listbox, with aria-activedescendant on the field.\n */\n\n/** The plain text of an option's content, for matching the search */\nfunction textOf(node: ReactNode): string {\n  if (node == null || typeof node === \"boolean\") return \"\";\n  if (typeof node === \"string\" || typeof node === \"number\") return String(node);\n  if (Array.isArray(node)) return node.map(textOf).join(\"\");\n  if (isValidElement<{ children?: ReactNode }>(node)) return textOf(node.props.children);\n  return \"\";\n}\n\nfunction Select(props: ComponentProps<typeof SelectPrimitive.Root>) {\n  return <SelectPrimitive.Root data-slot=\"select\" {...props} />;\n}\n\nfunction SelectGroup({ className, ...props }: ComponentProps<typeof SelectPrimitive.Group>) {\n  return (\n    <SelectPrimitive.Group\n      data-slot=\"select-group\"\n      // A group whose options are all filtered out by the search hides, label included\n      className={cn(\"[&:not(:has([data-slot=select-item]:not([hidden])))]:hidden\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SelectValue(props: ComponentProps<typeof SelectPrimitive.Value>) {\n  return <SelectPrimitive.Value data-slot=\"select-value\" {...props} />;\n}\n\nconst triggerSizes = {\n  sm: \"h-(--size-control-sm) rounded-md px-2.5\",\n  md: \"h-(--size-control-md) px-3\",\n} as const;\n\nfunction SelectTrigger({\n  className,\n  size = \"md\",\n  children,\n  ...props\n}: ComponentProps<typeof SelectPrimitive.Trigger> & { size?: keyof typeof triggerSizes }) {\n  return (\n    <SelectPrimitive.Trigger\n      data-slot=\"select-trigger\"\n      data-size={size}\n      className={cn(\n        \"group flex w-full min-w-0 cursor-pointer items-center justify-between gap-2 rounded-lg border border-input bg-transparent text-sm whitespace-nowrap shadow-xs\",\n        \"transition duration-(--duration-fast) ease-out\",\n        \"data-placeholder:text-muted-foreground *:data-[slot=select-value]:truncate\",\n        \"outline-ring focus-visible:border-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)\",\n        \"aria-invalid:border-destructive aria-invalid:outline-destructive\",\n        \"disabled:cursor-not-allowed disabled:opacity-(--opacity-disabled)\",\n        triggerSizes[size],\n        className,\n      )}\n      {...props}\n    >\n      {children}\n      <SelectPrimitive.Icon asChild>\n        <ChevronDownIcon className=\"size-(--size-icon-md) shrink-0 text-muted-foreground transition-transform duration-(--duration-base) ease-out group-data-[state=open]:rotate-180\" />\n      </SelectPrimitive.Icon>\n    </SelectPrimitive.Trigger>\n  );\n}\n\n/** Shared by the options of one opening: where the pill sits, and the search. It lives inside the content,\n * so every opening starts fresh. */\nconst ListContext = createContext<{\n  active: string | null;\n  setActive: (value: string | null | ((current: string | null) => string | null)) => void;\n  reduceMotion: boolean;\n  searchable: boolean;\n  /** The search, trimmed and lower-cased; empty without a search field or before typing */\n  query: string;\n} | null>(null);\n\n/** The soft edge shown where the list can scroll further (it replaces Radix's scroll arrows) */\nconst fade =\n  \"pointer-events-none absolute inset-x-0 z-10 h-6 from-popover to-transparent transition-opacity duration-(--duration-fast)\";\n\ntype SearchOptions = {\n  searchable: boolean;\n  searchPlaceholder: string;\n  emptyMessage: ReactNode | ((search: string) => ReactNode);\n};\n\nfunction SelectList({\n  children,\n  searchable,\n  searchPlaceholder,\n  emptyMessage,\n}: SearchOptions & { children: ReactNode }) {\n  const [active, setActive] = useState<string | null>(null);\n  const [activeId, setActiveId] = useState<string>();\n  const [search, setSearch] = useState(\"\");\n  const [empty, setEmpty] = useState(false);\n  const [edges, setEdges] = useState({ top: false, bottom: false });\n  const viewport = useRef<HTMLDivElement>(null);\n  const field = useRef<HTMLInputElement>(null);\n  const groupId = useId();\n  const listId = useId();\n  const reduceMotion = useReducedMotion() ?? false;\n  const query = searchable ? search.trim().toLowerCase() : \"\";\n\n  /** The options that can be picked right now: shown and enabled, in order */\n  const options = useCallback(\n    () =>\n      Array.from(viewport.current?.querySelectorAll<HTMLElement>(\"[data-slot=select-item]\") ?? []).filter(\n        (option) => !option.hidden && !option.hasAttribute(\"data-disabled\"),\n      ),\n    [],\n  );\n\n  // The fades at the ends show there's more to scroll to. The list's end is its last shown child:\n  // scrollHeight would also count the highlight while it glides, and leave a fade on after it stops.\n  const updateEdges = useCallback(() => {\n    const node = viewport.current;\n    if (!node) return;\n    const shown = Array.from(node.children).filter(\n      (child): child is HTMLElement => child instanceof HTMLElement && child.offsetParent !== null,\n    );\n    const last = shown[shown.length - 1];\n    const end = last ? last.offsetTop + last.offsetHeight : 0;\n    const top = node.scrollTop > 1;\n    const bottom = node.scrollTop + node.clientHeight < end - 1;\n    setEdges((current) => (current.top === top && current.bottom === bottom ? current : { top, bottom }));\n  }, []);\n\n  useLayoutEffect(() => {\n    const node = viewport.current;\n    if (!node) return;\n    updateEdges();\n    const observer = new ResizeObserver(updateEdges);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [updateEdges]);\n\n  // Opening, the selected option sits in the middle of the list rather than at its edge under a fade.\n  // Radix scrolls it just into view when it first takes focus; centre it on the next frame.\n  useEffect(() => {\n    const node = viewport.current;\n    const content = node?.closest<HTMLElement>(\"[data-slot=select-content]\");\n    if (!node || !content) return;\n    const centre = () =>\n      requestAnimationFrame(() => {\n        const selected = node.querySelector<HTMLElement>(\"[data-slot=select-item][data-state=checked]\");\n        if (selected && !selected.hidden)\n          node.scrollTop = selected.offsetTop - (node.clientHeight - selected.offsetHeight) / 2;\n        updateEdges();\n      });\n    content.addEventListener(\"focusin\", centre, { once: true });\n    return () => content.removeEventListener(\"focusin\", centre);\n  }, [updateEdges]);\n\n  // Each search starts at the top, with the first match highlighted for Enter\n  useLayoutEffect(() => {\n    if (!searchable) return;\n    const matches = options();\n    setEmpty(matches.length === 0);\n    if (query) {\n      if (viewport.current) viewport.current.scrollTop = 0;\n      setActive(matches[0]?.dataset.value ?? null);\n    }\n    updateEdges();\n  }, [query, searchable, options, updateEdges]);\n\n  // The field points screen readers at the highlighted option\n  useLayoutEffect(() => {\n    setActiveId(options().find((option) => option.dataset.value === active)?.id);\n  }, [active, options]);\n\n  // Keep the keyboard in the field. Radix first focuses the selected option (or the list), and focuses the\n  // list again when the pointer leaves an option. Typing while an option has focus (after hovering) goes to\n  // the field too, instead of Radix's typeahead.\n  useEffect(() => {\n    const input = field.current;\n    const content = input?.closest<HTMLElement>(\"[data-slot=select-content]\");\n    if (!searchable || !input || !content) return;\n    let first = true;\n    const onFocusIn = (event: FocusEvent) => {\n      if (event.target === input) return;\n      if (first || event.target === content) {\n        first = false;\n        input.focus({ preventScroll: true });\n      }\n    };\n    const onKeyDown = (event: globalThis.KeyboardEvent) => {\n      if (event.target === input || event.ctrlKey || event.metaKey || event.altKey) return;\n      if (event.key.length === 1 || event.key === \"Backspace\") {\n        event.stopPropagation();\n        input.focus({ preventScroll: true });\n      }\n    };\n    content.addEventListener(\"focusin\", onFocusIn);\n    content.addEventListener(\"keydown\", onKeyDown, true);\n    return () => {\n      content.removeEventListener(\"focusin\", onFocusIn);\n      content.removeEventListener(\"keydown\", onKeyDown, true);\n    };\n  }, [searchable]);\n\n  function onFieldKeyDown(event: KeyboardEvent<HTMLInputElement>) {\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      event.stopPropagation();\n      const matches = options();\n      if (matches.length === 0) return;\n      const index = matches.findIndex((option) => option.dataset.value === active);\n      const next =\n        index < 0\n          ? 0\n          : event.key === \"ArrowDown\"\n            ? Math.min(index + 1, matches.length - 1)\n            : Math.max(index - 1, 0);\n      const option = matches[next];\n      setActive(option?.dataset.value ?? null);\n      option?.scrollIntoView({ block: \"nearest\" });\n    } else if (event.key === \"Enter\") {\n      event.preventDefault();\n      event.stopPropagation();\n      const matches = options();\n      const option = matches.find((match) => match.dataset.value === active) ?? matches[0];\n      // Radix picks an option when the option itself receives Enter\n      option?.dispatchEvent(new globalThis.KeyboardEvent(\"keydown\", { key: \"Enter\", bubbles: true }));\n    } else if (event.key !== \"Escape\" && event.key !== \"Tab\") {\n      // Typing, Space, Home and End stay in the field rather than driving Radix's typeahead and list keys\n      event.stopPropagation();\n    }\n  }\n\n  return (\n    <ListContext.Provider value={{ active, setActive, reduceMotion, searchable, query }}>\n      {searchable ? (\n        <div\n          data-slot=\"select-search\"\n          className=\"relative flex items-center gap-2 border-b border-border px-3\"\n        >\n          <SearchIcon aria-hidden className=\"size-(--size-icon-sm) shrink-0 text-muted-foreground\" />\n          <input\n            ref={field}\n            role=\"combobox\"\n            aria-expanded=\"true\"\n            aria-controls={listId}\n            aria-autocomplete=\"list\"\n            aria-activedescendant={activeId}\n            aria-label={searchPlaceholder}\n            placeholder={searchPlaceholder}\n            value={search}\n            onChange={(event) => setSearch(event.target.value)}\n            onKeyDown={onFieldKeyDown}\n            onFocus={() => {\n              // Highlight something Enter can pick: what's already highlighted, else the selected option or the first\n              const matches = options();\n              setActive((current) =>\n                current && matches.some((option) => option.dataset.value === current)\n                  ? current\n                  : ((matches.find((option) => option.dataset.state === \"checked\") ?? matches[0])?.dataset\n                      .value ?? null),\n              );\n            }}\n            autoComplete=\"off\"\n            spellCheck={false}\n            className=\"h-(--size-control-md) w-full min-w-0 bg-transparent text-sm outline-hidden placeholder:text-muted-foreground\"\n          />\n          {/* The top fade hangs just below the field, over the list */}\n          <div\n            aria-hidden\n            className={cn(fade, \"top-full bg-linear-to-b\", edges.top ? \"opacity-100\" : \"opacity-0\")}\n          />\n        </div>\n      ) : (\n        <div\n          aria-hidden\n          className={cn(fade, \"top-0 bg-linear-to-b\", edges.top ? \"opacity-100\" : \"opacity-0\")}\n        />\n      )}\n      <SelectPrimitive.Viewport\n        asChild\n        {...(searchable ? { role: \"listbox\", id: listId, \"aria-label\": \"Options\" } : {})}\n      >\n        {/* layoutScroll keeps the pill in place while the list scrolls */}\n        <motion.div\n          ref={viewport}\n          layoutScroll\n          onScroll={updateEdges}\n          data-slot=\"select-viewport\"\n          className=\"max-h-72 w-full min-w-(--radix-select-trigger-width) scroll-my-1 p-1\"\n        >\n          <LayoutGroup id={groupId}>{children}</LayoutGroup>\n        </motion.div>\n      </SelectPrimitive.Viewport>\n      {/* The bottom fade lies over the popup's bottom edge, outside the list, so it never adds scroll height */}\n      <div\n        aria-hidden\n        className={cn(fade, \"bottom-0 bg-linear-to-t\", edges.bottom ? \"opacity-100\" : \"opacity-0\")}\n      />\n      {searchable && empty ? (\n        <p className=\"px-3 py-6 text-center text-sm text-muted-foreground\">\n          {typeof emptyMessage === \"function\" ? emptyMessage(search.trim()) : emptyMessage}\n        </p>\n      ) : null}\n    </ListContext.Provider>\n  );\n}\n\nexport type SelectContentProps = ComponentProps<typeof SelectPrimitive.Content> & {\n  /** Adds a search field above the options, for long lists */\n  searchable?: boolean;\n  /** The search field's placeholder and accessible name */\n  searchPlaceholder?: string;\n  /** Shown when nothing matches; a function receives the search */\n  emptyMessage?: ReactNode | ((search: string) => ReactNode);\n};\n\nfunction SelectContent({\n  className,\n  children,\n  position = \"popper\",\n  sideOffset = 4,\n  searchable = false,\n  searchPlaceholder = \"Search…\",\n  emptyMessage = (search: string) => `No results for “${search}”`,\n  ...props\n}: SelectContentProps) {\n  return (\n    <SelectPrimitive.Portal>\n      <SelectPrimitive.Content\n        data-slot=\"select-content\"\n        position={position}\n        sideOffset={sideOffset}\n        // With a search field the popup holds a field and a list, so it's a dialog and the list is the listbox\n        {...(searchable ? { role: \"dialog\", \"aria-label\": searchPlaceholder } : {})}\n        className={cn(\n          \"relative isolate z-(--z-popover) max-h-(--radix-select-content-available-height) min-w-(--radix-select-trigger-width) overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-md\",\n          \"origin-(--radix-select-content-transform-origin) data-[state=open]:animate-popover-in data-[state=closed]:animate-popover-out\",\n          className,\n        )}\n        {...props}\n      >\n        <SelectList searchable={searchable} searchPlaceholder={searchPlaceholder} emptyMessage={emptyMessage}>\n          {children}\n        </SelectList>\n      </SelectPrimitive.Content>\n    </SelectPrimitive.Portal>\n  );\n}\n\nfunction SelectLabel({ className, ...props }: ComponentProps<typeof SelectPrimitive.Label>) {\n  return (\n    <SelectPrimitive.Label\n      data-slot=\"select-label\"\n      className={cn(\"px-2 py-1.5 text-xs text-muted-foreground\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SelectItem({\n  className,\n  children,\n  value,\n  textValue,\n  id,\n  onFocus,\n  onBlur,\n  ...props\n}: ComponentProps<typeof SelectPrimitive.Item>) {\n  const list = useContext(ListContext);\n  const fallbackId = useId();\n  const active = list?.active === value;\n  // Hidden rather than removed when it doesn't match, so the trigger keeps the selected option's text\n  const hidden = !!list?.query && !(textValue ?? textOf(children)).toLowerCase().includes(list.query);\n\n  return (\n    <SelectPrimitive.Item\n      data-slot=\"select-item\"\n      data-value={value}\n      id={id ?? fallbackId}\n      value={value}\n      textValue={textValue}\n      hidden={hidden}\n      onFocus={(event) => {\n        list?.setActive(value);\n        onFocus?.(event);\n      }}\n      onBlur={(event) => {\n        // Moving to another option keeps the pill (it glides); leaving the list clears it. With a search\n        // field the pill stays on the last option, so Enter still knows what to pick.\n        const next = event.relatedTarget;\n        if (!list?.searchable && !(next instanceof HTMLElement && next.closest(\"[data-slot=select-item]\")))\n          list?.setActive(null);\n        onBlur?.(event);\n      }}\n      className={cn(\n        \"relative flex w-full cursor-default items-center gap-2 rounded-md py-1.5 ps-2 pe-8 text-sm outline-hidden select-none\",\n        \"data-highlighted:text-accent-foreground\",\n        \"data-disabled:pointer-events-none data-disabled:opacity-(--opacity-disabled)\",\n        \"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-(--size-icon-md)\",\n        className,\n      )}\n      {...props}\n    >\n      {/* Options are not stacking contexts: the pill joins the list's own layer (the content is `isolate`),\n          so it passes behind every option's text, whichever way it glides */}\n      {active ? (\n        <motion.span\n          layoutId=\"select-highlight\"\n          aria-hidden\n          className=\"absolute inset-0 -z-10 rounded-md bg-accent\"\n          transition={list?.reduceMotion ? { duration: 0 } : spring.snappy}\n        />\n      ) : null}\n      <span className=\"absolute end-2 flex size-(--size-icon-md) items-center justify-center\">\n        <SelectPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-(--size-icon-sm)\" />\n        </SelectPrimitive.ItemIndicator>\n      </span>\n      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n    </SelectPrimitive.Item>\n  );\n}\n\nfunction SelectSeparator({ className, ...props }: ComponentProps<typeof SelectPrimitive.Separator>) {\n  const list = useContext(ListContext);\n  return (\n    <SelectPrimitive.Separator\n      data-slot=\"select-separator\"\n      // Groups are what the separators divide; while searching, the matches read as one list\n      hidden={!!list?.query}\n      className={cn(\"pointer-events-none -mx-1 my-1 h-px bg-border\", className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectSeparator,\n  SelectTrigger,\n  SelectValue,\n};\n"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Usage and examples: https://ui.fewtone.com/components/select"
}
