{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"search-input","type":"registry:component","title":"Search input","description":"The field at the top of every list: ⌘K focuses it from anywhere, search runs after a pause, the magnifier blurs into a spinner while it loads.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/search-input.tsx","type":"registry:component","content":"// Hairline UI · search-input · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { Input, type InputProps } from \"./input\";\nimport { EASE_OUT, interactionIcon, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * SEARCH INPUT: the field at the top of every list\n *\n *   shortcut  a ⌘K (or \"/\") hint sits at the end; the key\n *             focuses the field from anywhere on the page, and\n *             the hint steps aside once you type\n *   search    onSearch runs after a pause in typing (debounced);\n *             Enter runs it at once, Escape clears and leaves\n *   loading   the magnifier blurs into a spinner and back\n *   clear     the clear button folds in with the first letter\n * ───────────────────────────────────────────────────────── */\n\nexport interface SearchInputProps extends Omit<InputProps, \"leading\" | \"type\" | \"clearable\"> {\n  /** Called with the query after `debounce` ms without typing, and at once on Enter. */\n  onSearch?: (query: string) => void;\n  debounce?: number;\n  /** Shows a spinner in place of the magnifier. */\n  loading?: boolean;\n  /** \"mod+k\" (⌘K / Ctrl K), \"/\" or null for no shortcut. */\n  shortcut?: \"mod+k\" | \"/\" | null;\n}\n\nfunction Magnifier() {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3.5\">\n      <circle cx=\"7\" cy=\"7\" r=\"4.6\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n      <path d=\"M10.4 10.4 13.5 13.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nconst subscribeNoop = () => () => {};\nconst isMac = () => /Mac|iPhone|iPad/.test(navigator.platform);\n\nexport function SearchInput({\n  onSearch,\n  debounce = 250,\n  loading = false,\n  shortcut = \"mod+k\",\n  placeholder = \"Search\",\n  value,\n  defaultValue,\n  onValueChange,\n  onKeyDown,\n  onFocus,\n  onBlur,\n  size = \"md\",\n  ref,\n  ...props\n}: SearchInputProps) {\n  const reduced = useInteractionReducedMotion();\n  const inner = React.useRef<HTMLInputElement>(null);\n  const [text, setText] = React.useState(String(defaultValue ?? \"\"));\n  const [focused, setFocused] = React.useState(false);\n  const current = value !== undefined ? String(value) : text;\n  const mac = React.useSyncExternalStore(subscribeNoop, isMac, () => true);\n  const searchRef = React.useRef(onSearch);\n  React.useEffect(() => {\n    searchRef.current = onSearch;\n  });\n\n  // Debounced search: each keystroke restarts the wait.\n  const first = React.useRef(true);\n  React.useEffect(() => {\n    if (first.current) {\n      first.current = false;\n      return;\n    }\n    const timer = window.setTimeout(() => searchRef.current?.(current), debounce);\n    return () => window.clearTimeout(timer);\n  }, [current, debounce]);\n\n  // The shortcut focuses the field from anywhere, unless you're typing somewhere else.\n  React.useEffect(() => {\n    if (!shortcut) return;\n    const onKey = (e: KeyboardEvent) => {\n      const target = e.target as HTMLElement;\n      const typing = target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName);\n      const hit = shortcut === \"/\" ? e.key === \"/\" && !typing : e.key.toLowerCase() === \"k\" && (e.metaKey || e.ctrlKey);\n      if (!hit) return;\n      e.preventDefault();\n      inner.current?.focus();\n      inner.current?.select();\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [shortcut]);\n\n  const showKey = !!shortcut && !focused && !current;\n  const keyLabel = shortcut === \"/\" ? \"/\" : mac ? \"⌘K\" : \"Ctrl K\";\n\n  return (\n    <Input\n      {...props}\n      ref={(el) => {\n        inner.current = el;\n        if (typeof ref === \"function\") ref(el);\n        else if (ref) (ref as React.RefObject<HTMLInputElement | null>).current = el;\n      }}\n      type=\"search\"\n      role=\"searchbox\"\n      size={size}\n      clearable\n      placeholder={placeholder}\n      value={value}\n      defaultValue={defaultValue}\n      inputClassName=\"[&::-webkit-search-cancel-button]:appearance-none\"\n      onValueChange={(v) => {\n        if (value === undefined) setText(v);\n        onValueChange?.(v);\n      }}\n      onFocus={(e) => {\n        setFocused(true);\n        onFocus?.(e);\n      }}\n      onBlur={(e) => {\n        setFocused(false);\n        onBlur?.(e);\n      }}\n      onKeyDown={(e) => {\n        onKeyDown?.(e);\n        if (e.key === \"Enter\") onSearch?.(current);\n        if (e.key === \"Escape\" && current) {\n          e.preventDefault();\n          e.currentTarget.blur();\n        }\n      }}\n      leading={\n        <span className=\"relative size-3.5\" aria-hidden=\"true\">\n          <span className=\"absolute inset-0 flex items-center justify-center\" style={interactionIcon(!loading, reduced)}>\n            <Magnifier />\n          </span>\n          <span className=\"absolute inset-0 flex items-center justify-center\" style={interactionIcon(loading, reduced)}>\n            <span className={`size-3 rounded-full border-[1.5px] border-current border-t-transparent ${loading ? \"animate-spin\" : \"\"}`} />\n          </span>\n        </span>\n      }\n      trailing={\n        shortcut ? (\n          <span\n            className=\"grid\"\n            style={{ gridTemplateColumns: showKey ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-columns 320ms ${EASE_OUT}` }}\n          >\n            <span className=\"min-w-0 overflow-hidden\">\n              <kbd\n                aria-hidden=\"true\"\n                className={`mr-1.5 block whitespace-nowrap rounded-full px-1.5 font-mono text-[10.5px] leading-[18px] text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]`}\n                style={{ opacity: showKey ? 1 : 0, filter: showKey ? \"none\" : \"blur(3px)\", transition: reduced ? \"none\" : `opacity 200ms ${EASE_OUT}, filter 200ms ${EASE_OUT}` }}\n              >\n                {keyLabel}\n              </kbd>\n            </span>\n          </span>\n        ) : undefined\n      }\n    />\n  );\n}\n\nexport default SearchInput;\n"},{"path":"registry/field.tsx","type":"registry:component","content":"// Hairline UI · search-input · 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, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * FIELD: the column every text control sits in\n *\n *   label    above, with an optional aside on the right (a count)\n *   hint     under the control while all is well\n *   error    takes the hint's place: the hint folds away, the\n *            error rises in out of a blur, the control shakes once\n *\n * Shared by input, textarea, password, search and select so the\n * whole family has one surface, one focus ring and one error.\n * ───────────────────────────────────────────────────────── */\n\nexport interface FieldProps {\n  id: string;\n  label?: React.ReactNode;\n  /** Right side of the label row, e.g. a character count. */\n  aside?: React.ReactNode;\n  hint?: React.ReactNode;\n  error?: React.ReactNode;\n  required?: boolean;\n  className?: string;\n  children: React.ReactNode;\n}\n\nexport type FieldSize = \"sm\" | \"md\";\n\n/** The surface of a text control: a hairline that darkens on hover and turns to the ring on focus. */\nexport function fieldSurface({ invalid, disabled, round = true }: { invalid?: boolean; disabled?: boolean; round?: boolean }) {\n  const ring = invalid\n    ? \"shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_55%,transparent)] focus-within:shadow-[inset_0_0_0_1px_var(--color-red-500),0_0_0_3px_color-mix(in_oklab,var(--color-red-500)_14%,transparent)]\"\n    : \"shadow-[inset_0_0_0_1px_var(--border)] [&:not(:focus-within)]:hover:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_24%,var(--border))] focus-within:shadow-[inset_0_0_0_1px_var(--ring),0_0_0_3px_color-mix(in_oklab,var(--ring)_16%,transparent)]\";\n  return `relative flex w-full items-center bg-background text-foreground transition-[box-shadow,background-color,opacity] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${round ? \"rounded-full\" : \"rounded-[14px]\"} ${ring} ${\n    disabled ? \"pointer-events-none opacity-50\" : \"\"\n  }`;\n}\n\n/** ids for the control's label, hint and error. */\nexport function useFieldIds(id?: string) {\n  const auto = React.useId();\n  const base = id ?? auto;\n  return { id: base, hint: `${base}-hint`, error: `${base}-error` };\n}\n\n/** One short shake whenever `on` turns true (an error arrives). */\nexport function useShake<T extends HTMLElement>(on: boolean) {\n  const ref = React.useRef<T>(null);\n  const reduced = useInteractionReducedMotion();\n  React.useEffect(() => {\n    if (!on || reduced) return;\n    ref.current?.animate(\n      [{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }],\n      { duration: 380, easing: \"ease-out\" },\n    );\n  }, [on, reduced]);\n  return ref;\n}\n\n/** Sets a native input's value so React's onChange still fires (clear buttons, pickers). */\nexport function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {\n  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;\n  Object.getOwnPropertyDescriptor(proto, \"value\")?.set?.call(el, value);\n  el.dispatchEvent(new Event(\"input\", { bubbles: true }));\n}\n\n/** A line under the control that folds open and closed; keeps its last words while closing. */\nfunction Line({ show, children, id, tone, reduced }: { show: boolean; children: React.ReactNode; id: string; tone: string; reduced: boolean }) {\n  const [kept, setKept] = React.useState(children);\n  if (show && children !== kept) setKept(children);\n  return (\n    <div\n      className=\"grid\"\n      style={{ gridTemplateRows: show ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-rows 320ms ${EASE_OUT}` }}\n    >\n      <div className=\"min-h-0 overflow-hidden\">\n        <p\n          id={id}\n          className={`pt-1.5 pl-3 text-[12.5px] leading-snug ${tone}`}\n          style={{\n            opacity: show ? 1 : 0,\n            filter: show ? \"blur(0px)\" : \"blur(4px)\",\n            transform: show ? \"none\" : \"translateY(-4px)\",\n            transition: reduced ? \"none\" : show ? `opacity 280ms ${EASE_OUT} 60ms, filter 280ms ${EASE_OUT} 60ms, transform 380ms ${EASE_OUT}` : `opacity 140ms ${EASE_OUT}, filter 140ms ${EASE_OUT}, transform 200ms ${EASE_OUT}`,\n          }}\n        >\n          {show ? children : kept}\n        </p>\n      </div>\n    </div>\n  );\n}\n\nexport function Field({ id, label, aside, hint, error, required, className = \"\", children }: FieldProps) {\n  const reduced = useInteractionReducedMotion();\n  const ids = { hint: `${id}-hint`, error: `${id}-error` };\n  return (\n    <div className={`flex w-full flex-col ${className}`}>\n      {(label || aside) && (\n        <div className=\"flex items-baseline justify-between gap-3 px-3 pb-1.5\">\n          {label && (\n            <label htmlFor={id} className=\"text-[13px] font-medium text-foreground\">\n              {label}\n              {required && (\n                <span aria-hidden=\"true\" className=\"ml-0.5 text-muted-foreground\">\n                  *\n                </span>\n              )}\n            </label>\n          )}\n          {aside && <span className=\"text-[12px] tabular-nums text-muted-foreground\">{aside}</span>}\n        </div>\n      )}\n      {children}\n      <Line show={!error && !!hint} id={ids.hint} tone=\"text-muted-foreground\" reduced={reduced}>\n        {hint}\n      </Line>\n      <Line show={!!error} id={ids.error} tone=\"text-red-600 dark:text-red-400\" reduced={reduced}>\n        {error}\n      </Line>\n    </div>\n  );\n}\n\nexport default Field;\n"},{"path":"registry/input.tsx","type":"registry:component","content":"// Hairline UI · search-input · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { Field, fieldSurface, setNativeValue, useFieldIds, useShake, type FieldSize } from \"./field\";\nimport { EASE_OUT, INTERACTION_FOCUS, interactionIcon, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * INPUT: a one-line field\n *\n *   rest     a hairline pill; hover darkens the line\n *   focus    the line turns to the ring with a 3px halo\n *   typing   a clear button folds in once there is text and\n *            folds away when it's gone (it hands focus back)\n *   error    the line turns red, the hint gives way to the\n *            error and the field shakes once\n * ───────────────────────────────────────────────────────── */\n\nexport interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, \"size\"> {\n  label?: React.ReactNode;\n  hint?: React.ReactNode;\n  /** Shows red and replaces the hint. */\n  error?: React.ReactNode;\n  /** An icon or text before the value. */\n  leading?: React.ReactNode;\n  /** An icon, text or button after the value. */\n  trailing?: React.ReactNode;\n  /** A button that empties the field once it has text. */\n  clearable?: boolean;\n  /** 28px or 36px tall. */\n  size?: FieldSize;\n  /** Called with the new text on every change. */\n  onValueChange?: (value: string) => void;\n  ref?: React.Ref<HTMLInputElement>;\n  /** Class for the outer column. */\n  className?: string;\n  /** Class for the input element itself. */\n  inputClassName?: string;\n}\n\nfunction ClearIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3.5\">\n      <path d=\"M4.5 4.5l7 7M11.5 4.5l-7 7\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nexport function Input({\n  label,\n  hint,\n  error,\n  leading,\n  trailing,\n  clearable,\n  size = \"md\",\n  onValueChange,\n  ref,\n  id,\n  className = \"\",\n  inputClassName = \"\",\n  value,\n  defaultValue,\n  onChange,\n  disabled,\n  required,\n  ...props\n}: InputProps) {\n  const reduced = useInteractionReducedMotion();\n  const ids = useFieldIds(id);\n  const inner = React.useRef<HTMLInputElement>(null);\n  const [text, setText] = React.useState(String(defaultValue ?? \"\"));\n  const current = value !== undefined ? String(value) : text;\n  const shakeRef = useShake<HTMLDivElement>(!!error);\n  const sm = size === \"sm\";\n  const showClear = !!clearable && current.length > 0 && !disabled;\n\n  const setRefs = (el: HTMLInputElement | null) => {\n    inner.current = el;\n    if (typeof ref === \"function\") ref(el);\n    else if (ref) (ref as React.RefObject<HTMLInputElement | null>).current = el;\n  };\n\n  const describedBy = [hint && !error ? ids.hint : null, error ? ids.error : null].filter(Boolean).join(\" \") || undefined;\n\n  return (\n    <Field id={ids.id} label={label} hint={hint} error={error} required={required} className={className}>\n      <div ref={shakeRef} className={`${fieldSurface({ invalid: !!error, disabled })} ${sm ? \"h-7 text-[12.5px]\" : \"h-9 text-[13.5px]\"}`}>\n        {leading && <span className={`flex shrink-0 items-center text-muted-foreground ${sm ? \"pl-2.5\" : \"pl-3.5\"} [&_svg]:size-3.5`}>{leading}</span>}\n        <input\n          ref={setRefs}\n          id={ids.id}\n          value={value}\n          defaultValue={defaultValue}\n          disabled={disabled}\n          required={required}\n          aria-invalid={error ? true : undefined}\n          aria-describedby={describedBy}\n          onChange={(e) => {\n            if (value === undefined) setText(e.target.value);\n            onChange?.(e);\n            onValueChange?.(e.target.value);\n          }}\n          className={`h-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground/70 ${leading ? \"pl-2\" : sm ? \"pl-3\" : \"pl-4\"} ${trailing || clearable ? \"pr-1\" : sm ? \"pr-3\" : \"pr-4\"} ${inputClassName}`}\n          {...props}\n        />\n        {clearable && (\n          // The clear button folds its own width open, so the text never jumps.\n          <span\n            className=\"grid shrink-0\"\n            style={{ gridTemplateColumns: showClear ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-columns 320ms ${EASE_OUT}` }}\n          >\n            <span className=\"min-w-0 overflow-hidden\">\n              <button\n                type=\"button\"\n                tabIndex={showClear ? 0 : -1}\n                aria-hidden={!showClear}\n                aria-label=\"Clear\"\n                onClick={() => {\n                  if (!inner.current) return;\n                  setNativeValue(inner.current, \"\");\n                  inner.current.focus();\n                }}\n                className={`mr-1 flex items-center justify-center rounded-full text-muted-foreground cursor-pointer hover:bg-accent hover:text-foreground ${sm ? \"size-5\" : \"size-7\"} ${INTERACTION_FOCUS}`}\n                style={interactionIcon(showClear, reduced)}\n              >\n                <ClearIcon />\n              </button>\n            </span>\n          </span>\n        )}\n        {trailing && <span className={`flex shrink-0 items-center text-muted-foreground ${sm ? \"pr-1\" : \"pr-1.5\"} [&_svg]:size-3.5`}>{trailing}</span>}\n      </div>\n    </Field>\n  );\n}\n\nexport default Input;\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/search-input. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}