Skip to content

Search box whose shortcut hint turns into a clear, with a patient spinner.

Text inputs@base-ui/reactmotion

01Preview

Team

5 members
  • Maya ChenDesign engineer
  • Tomás OrtegaInfrastructure
  • Priya RamanProduct
  • Jonah FischerSupport lead
  • Aiko TanakaData

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install @base-ui/react motion

03Usage

import { SearchField } from "@/components/ui/search-field";

<SearchField
  placeholder="Search projects"
  value={query}
  onValueChange={setQuery}
  loading={isFetching}
/>

// Folds into a square icon button in a toolbar.
<SearchField collapsible shortcut={false} size="sm" aria-label="Search activity" />

04Source

"use client";
import { Input as BaseInput } from "@base-ui/react/input";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { Loader, Search, X } from "@/lib/icons";
import { spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";

export type SearchFieldProps = Omit<React.ComponentProps<"input">, "size" | "value" | "defaultValue" | "type"> & {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Called with the query when Enter is pressed. */
  onSearch?: (query: string) => void;
  /** Swaps the magnifier for a spinner. Waits 150ms before showing and holds 300ms once shown, so fast results never flash. */
  loading?: boolean;
  /** Focuses the field from anywhere: "mod+k" (⌘K on Mac, Ctrl K elsewhere) or a single key like "/" that never fires while typing. false for none. */
  shortcut?: string | false;
  /** Starts as a square icon button and springs open to full width; folds back when it loses focus empty. */
  collapsible?: boolean;
  size?: "sm" | "md" | "lg";
  /** Classes for the inner input; `className` styles the outer box. */
  inputClassName?: string;
};

const subscribe = () => () => {};
const isMacNow = () => /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
function useIsMac() {
  return useSyncExternalStore(subscribe, isMacNow, () => true);
}

/** Holds a busy flag back for `delay` ms and keeps it for at least `min` ms once shown. */
export function useDelayedBusy(busy: boolean, { delay = 150, min = 300 } = {}) {
  const [shown, setShown] = useState(false);
  const since = useRef(0);
  useEffect(() => {
    let t: number;
    if (busy && !shown) {
      t = window.setTimeout(() => {
        since.current = performance.now();
        setShown(true);
      }, delay);
    } else if (!busy && shown) {
      const left = Math.max(0, min - (performance.now() - since.current));
      t = window.setTimeout(() => setShown(false), left);
    }
    return () => window.clearTimeout(t);
  }, [busy, shown, delay, min]);
  return shown;
}

function parseShortcut(s: string) {
  const parts = s.toLowerCase().split("+");
  return { mod: parts.includes("mod"), shift: parts.includes("shift"), key: parts[parts.length - 1] };
}

const isTyping = (el: Element | null) =>
  !!el && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement || (el as HTMLElement).isContentEditable);

const sizes = {
  // Left padding centers the icon in the folded square, so opening never nudges it.
  sm: { box: "h-7 rounded-md pl-1.5 pr-1 gap-1.5 text-base sm:text-[12.5px]", square: 28, icon: 14 },
  md: { box: "h-8 rounded-lg pl-[7px] pr-1.5 gap-2 text-base sm:text-[13px]", square: 32, icon: 16 },
  lg: { box: "h-9 rounded-lg pl-[9px] pr-2 gap-2 text-base sm:text-[13px]", square: 36, icon: 16 },
};

export function SearchField({
  value: valueProp,
  defaultValue = "",
  onValueChange,
  onSearch,
  loading = false,
  shortcut = "mod+k",
  collapsible = false,
  size = "md",
  placeholder = "Search",
  disabled,
  className,
  inputClassName,
  onKeyDown,
  onBlur,
  ref,
  ...rest
}: SearchFieldProps) {
  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });
  const reduce = useReducedMotion();
  const mac = useIsMac();
  const busy = useDelayedBusy(loading);
  const inner = useRef<HTMLInputElement | null>(null);
  const [open, setOpen] = useState(!collapsible || defaultValue !== "");
  // Opening from the keyboard is instant; opening from a press gets the spring.
  const [instant, setInstant] = useState(false);
  const expanded = !collapsible || open || value !== "";
  const s = sizes[size];

  const setRef = useCallback(
    (node: HTMLInputElement | null) => {
      inner.current = node;
      if (typeof ref === "function") ref(node);
      else if (ref) ref.current = node;
    },
    [ref],
  );

  useEffect(() => {
    if (!shortcut || disabled) return;
    const want = parseShortcut(shortcut);
    const onKey = (e: KeyboardEvent) => {
      if (e.defaultPrevented || e.repeat) return;
      const mod = mac ? e.metaKey : e.ctrlKey;
      if (e.key.toLowerCase() !== want.key || mod !== want.mod || e.shiftKey !== want.shift || e.altKey) return;
      if (!want.mod && (isTyping(document.activeElement) || e.metaKey || e.ctrlKey)) return;
      e.preventDefault();
      setInstant(true);
      setOpen(true);
      // The field may still be mounting its open state; focus on the next frame.
      requestAnimationFrame(() => {
        inner.current?.focus();
        inner.current?.select();
      });
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [shortcut, disabled, mac]);

  const hint = shortcut
    ? (() => {
        const p = parseShortcut(shortcut);
        const key = p.key.length === 1 ? p.key.toUpperCase() : p.key;
        return `${p.mod ? (mac ? "" : "Ctrl ") : ""}${p.shift ? (mac ? "" : "Shift ") : ""}${key}`;
      })()
    : null;

  const clear = () => {
    setValue("");
    inner.current?.focus();
  };

  const showClear = value !== "" && !disabled;
  const width = expanded ? "100%" : s.square;

  return (
    <div className={cn("flex w-full min-w-0", collapsible && "justify-end")}>
      <motion.div
        data-slot="search-field"
        data-size={size}
        data-state={expanded ? "open" : "closed"}
        data-disabled={disabled || undefined}
        initial={false}
        animate={{ width }}
        transition={instant || reduce ? { duration: 0 } : spring.snappy}
        onAnimationComplete={() => setInstant(false)}
        className={cn(
          "group/search relative flex min-w-0 items-center overflow-hidden border border-line-2 bg-raised text-fg shadow-[var(--shadow)]",
          "transition-[border-color,box-shadow,background-color,scale] duration-150 ease-out",
          "hover:border-fg-4 focus-within:border-fg-3 focus-within:ring-3 focus-within:ring-fg/8 hover:focus-within:border-fg-3",
          "data-[state=closed]:hover:bg-hover data-[state=closed]:active:scale-[0.94] data-[state=closed]:active:duration-75",
          "data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:shadow-none data-disabled:hover:border-line-2",
          s.box,
          !collapsible && "w-full",
          className,
        )}
        onMouseDown={(e) => {
          const t = e.target as HTMLElement;
          if (t.closest("input, button") || disabled) return;
          e.preventDefault();
          inner.current?.focus();
        }}
      >
        {/* Magnifier and spinner share one box, so the text never moves when loading starts. */}
        <span aria-hidden className="relative grid shrink-0 place-items-center text-fg-3 transition-colors duration-150 group-focus-within/search:text-fg-2" style={{ width: s.icon, height: s.icon }}>
          <AnimatePresence initial={false}>
            <motion.span
              key={busy ? "busy" : "idle"}
              className="absolute inset-0 grid place-items-center"
              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: "blur(2px)" }}
              animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: "blur(2px)" }}
              transition={reduce ? { duration: 0.12 } : spring.pop}
            >
              {busy ? <Loader size={s.icon} className="animate-spin" /> : <Search size={s.icon} />}
            </motion.span>
          </AnimatePresence>
        </span>

        <BaseInput
          ref={setRef}
          type="search"
          value={value}
          onValueChange={(next) => setValue(next)}
          disabled={disabled}
          placeholder={placeholder}
          aria-label={rest["aria-label"] ?? (rest["aria-labelledby"] ? undefined : placeholder)}
          aria-busy={loading || undefined}
          enterKeyHint="search"
          autoComplete="off"
          spellCheck={false}
          tabIndex={expanded ? undefined : -1}
          onKeyDown={(e) => {
            onKeyDown?.(e);
            if (e.defaultPrevented) return;
            if (e.key === "Escape") {
              // First Escape empties the field; the next one lets go of it.
              if (e.currentTarget.value) {
                e.preventDefault();
                e.stopPropagation();
                setValue("");
              } else {
                e.currentTarget.blur();
              }
            } else if (e.key === "Enter" && !e.nativeEvent.isComposing) {
              onSearch?.(e.currentTarget.value);
            }
          }}
          onBlur={(e) => {
            onBlur?.(e);
            if (collapsible && !e.currentTarget.value) setOpen(false);
          }}
          className={cn(
            "h-full min-w-0 flex-1 bg-transparent text-inherit outline-none placeholder:text-fg-4 disabled:cursor-not-allowed",
            "[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none",
            "transition-opacity duration-150 group-data-[state=closed]/search:pointer-events-none group-data-[state=closed]/search:opacity-0",
            inputClassName,
          )}
          {...rest}
        />

        {/* Hint and clear stack in one cell: the hint says how to get here, the clear says how to start over. */}
        <span className="grid shrink-0 place-items-center justify-items-end transition-opacity duration-150 group-data-[state=closed]/search:opacity-0">
          {hint && (
            <kbd
              aria-hidden
              data-state={showClear ? "hidden" : "visible"}
              className={cn(
                "col-start-1 row-start-1 inline-flex h-5 min-w-5 items-center justify-center rounded-[5px] border border-line-2 bg-frame px-1 font-mono text-[10.5px] leading-none text-fg-3 pointer-coarse:hidden",
                "transition-[opacity,scale,filter] duration-200 ease-out-expo",
                "data-[state=hidden]:scale-75 data-[state=hidden]:opacity-0 data-[state=hidden]:blur-[2px] data-[state=hidden]:duration-100",
                "motion-reduce:data-[state=hidden]:scale-100 motion-reduce:data-[state=hidden]:blur-none",
              )}
            >
              {hint}
            </kbd>
          )}
          <button
            type="button"
            tabIndex={-1}
            aria-label="Clear search"
            inert={!showClear}
            data-state={showClear ? "visible" : "hidden"}
            onClick={clear}
            className={cn(
              "relative col-start-1 row-start-1 grid size-5 place-items-center rounded-[5px] text-fg-3 outline-none",
              "before:absolute before:-inset-1.5 before:content-[''] pointer-coarse:before:-inset-3",
              "hover:bg-hover hover:text-fg active:scale-[0.88]",
              "transition-[opacity,scale,filter,background-color,color] duration-200 ease-out-expo",
              "data-[state=hidden]:pointer-events-none data-[state=hidden]:scale-75 data-[state=hidden]:opacity-0 data-[state=hidden]:blur-[2px] data-[state=hidden]:duration-100",
              "motion-reduce:data-[state=hidden]:scale-100 motion-reduce:data-[state=hidden]:blur-none",
            )}
          >
            <X size={size === "sm" ? 12 : 14} />
          </button>
        </span>

        {collapsible && !expanded && (
          // While folded, the whole square is one button that opens the field.
          <button
            type="button"
            aria-label={rest["aria-label"] ?? (typeof placeholder === "string" ? placeholder : "Search")}
            aria-expanded={false}
            disabled={disabled}
            onClick={() => {
              setInstant(false);
              setOpen(true);
              requestAnimationFrame(() => inner.current?.focus());
            }}
            className="absolute inset-0 rounded-[inherit] outline-none focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 focus-visible:outline-solid"
          />
        )}
      </motion.div>
    </div>
  );
}

05Props

SearchField

PropTypeDefaultDescription
valuestringThe query, when controlled. Pair with onValueChange.
defaultValuestring""The starting query, when uncontrolled.
onValueChange(value: string) => voidCalled on every edit, on clear and when Escape empties the field.
onSearch(query: string) => voidCalled with the query when Enter is pressed.
loadingbooleanfalseSwaps the magnifier for a spinner, after 150ms and for at least 300ms. Sets aria-busy.
shortcutstring | false"mod+k""mod+k" is ⌘K on Mac and Ctrl K elsewhere; a single key like "/" never fires while typing. Shown as a keycap while empty.
collapsiblebooleanfalseStarts as a square icon button, springs open to the width of its container and folds back when it loses focus empty.
size"sm" | "md" | "lg""md"28, 32 or 36px tall.
placeholderstring"Search"Also the accessible name when no aria-label is given.
classNamestringClasses for the box. Cap a collapsible field's open width with max-w-*.
inputClassNamestringClasses for the input.

useDelayedBusy

PropTypeDefaultDescription
busy*booleanThe raw loading flag. Returns whether to show the indicator.
options{ delay?: number; min?: number }{ delay: 150, min: 300 }Wait before showing; minimum time shown once visible.

06Notes

Behavior

  • Escape empties the field and keeps focus; a second Escape lets go. The first one stops propagating, so a dialog around the field stays open while you clear it.
  • The shortcut keycap and the clear button share one slot: the hint says how to get here, and turns into the way to start over once there is text. Neither moves the text.
  • The spinner waits 150ms before replacing the magnifier and stays at least 300ms, so fast results never flash an indicator and slow ones never flicker.
  • The single-key shortcut is ignored while any input, textarea or editable region has focus; the ⌘K form works everywhere and selects the existing query so typing replaces it.

Motion

  • Magnifier and spinner swap in place on the pop spring (600/30) from scale 0.5 with a 2px blur.
  • Hint and clear cross over in 200ms on the expo ease-out, leaving in 100ms.
  • The collapsible variant springs open on the snappy spring (520/38) when pressed, but opens on the same frame from the keyboard shortcut, because keyboard actions shouldn't wait for an animation.
  • Reduced motion keeps opacity changes and opens the collapsible field instantly.

Accessibility

  • A native type=search input via Base UI Input, named by aria-label or its placeholder, with enterkeyhint=search for phone keyboards.
  • While folded, the square is a button named after the field with aria-expanded=false; the hidden input is out of the Tab order until it opens.
  • The clear button is labeled Clear search and kept out of the Tab order, since Escape does the same. aria-busy marks the field while loading.