For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Search Field

A text field for a query, with a search icon, a clear button and an optional loading light.

Installation

$ pnpm dlx shadcn@latest add @cobalt/search-field

Usage

import { SearchField } from "@/components/ui/search-field";
 
<SearchField
  label="Search teammates"
  placeholder="Search by name or email"
  onClear={() => resetFilters()}
/>;

Escape clears the field and Enter calls onSubmit when provided. The clear button stays out of the tab order because Escape does its job from the keyboard.

variant="muted" recesses the field into the surface, with the same fill as InputGroup's muted variant, for toolbars on a card. The default is variant="default".

Source

"use client";

import { Input as InputPrimitive } from "@base-ui/react/input";
import { RiSearchLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";

type IconComponent = React.ComponentType<{
  className?: string;
  "aria-hidden"?: boolean | "true" | "false";
}>;

export interface SearchFieldProps extends Omit<
  React.ComponentProps<"input">,
  | "children"
  | "className"
  | "defaultValue"
  | "onChange"
  | "onSubmit"
  | "ref"
  | "size"
  | "value"
> {
  label?: React.ReactNode;
  hint?: React.ReactNode;
  /** 36px (`default`) or 32px (`sm`). */
  size?: "default" | "sm";
  /** `pill` (default) or `rounded`, Input's corners. */
  shape?: "pill" | "rounded";
  /** `default` (page surface) or `muted` (recessed well, as InputGroup's muted). */
  variant?: "default" | "muted";
  /** Show Spinner's light in place of the icon while results load. */
  isLoading?: boolean;
  /** Replaces the search icon. */
  icon?: IconComponent;
  /** Accessible name of the clear button. Default "Clear search". */
  clearLabel?: string;
  className?: string;
  fieldClassName?: string;
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
  onClear?: () => void;
  onSubmit?: (value: string) => void;
}

function SearchField({
  label,
  hint,
  placeholder,
  size = "default",
  shape = "pill",
  variant = "default",
  isLoading = false,
  icon: Icon = RiSearchLine,
  clearLabel = "Clear search",
  className,
  fieldClassName,
  value: controlledValue,
  defaultValue = "",
  onChange,
  onClear,
  onSubmit,
  disabled = false,
  readOnly = false,
  required = false,
  "aria-label": ariaLabel,
  "aria-invalid": ariaInvalid,
  "aria-describedby": ariaDescribedBy,
  onKeyDown,
  ...inputProps
}: SearchFieldProps) {
  const inputRef = React.useRef<HTMLInputElement>(null);
  const generatedId = React.useId();
  const inputId = inputProps.id ?? generatedId;
  const hintId = React.useId();

  const [internalValue, setInternalValue] = React.useState(defaultValue);
  const isControlled = controlledValue !== undefined;
  const inputValue = isControlled ? controlledValue : internalValue;
  const isEmpty = inputValue === "";
  const isInvalid = ariaInvalid === true || ariaInvalid === "true";

  const change = (next: string) => {
    if (!isControlled) {
      setInternalValue(next);
    }
    onChange?.(next);
  };

  const clear = () => {
    change("");
    onClear?.();
    inputRef.current?.focus();
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    onKeyDown?.(event);
    if (event.defaultPrevented) {
      return;
    }

    if (event.key === "Enter" && onSubmit) {
      event.preventDefault();
      onSubmit(inputValue);
      return;
    }

    if (event.key === "Escape" && !disabled && !readOnly) {
      if (inputValue === "" && (inputRef.current?.value ?? "") === "") {
        return;
      }
      event.preventDefault();
      change("");
      onClear?.();
    }
  };

  return (
    <div
      data-slot="search-field"
      data-disabled={disabled ? "true" : undefined}
      data-invalid={isInvalid ? "true" : undefined}
      className={cn("group flex w-full flex-col items-start gap-1", className)}
    >
      {label ? (
        <Label
          data-slot="search-field-label"
          htmlFor={inputId}
          className="cursor-default gap-0.5 leading-5 text-foreground"
        >
          {label}
          {required && (
            <span
              aria-hidden="true"
              className="text-[color:var(--field-invalid,var(--destructive))]"
            >
              *
            </span>
          )}
        </Label>
      ) : null}
      <div
        data-slot="search-field-control"
        data-shape={shape}
        data-variant={variant}
        data-disabled={disabled ? "true" : undefined}
        data-invalid={isInvalid ? "true" : undefined}
        className={cn(
          "relative flex w-full items-center ring-2 ring-transparent ring-inset transition-[background-color,box-shadow,color] duration-150 ease-[ease] outline-none motion-reduce:transition-none",
          variant === "muted"
            ? "rounded-full border-transparent bg-[var(--surface-sunken,var(--muted))] text-[color:var(--foreground-subtle,var(--muted-foreground))]"
            : "rounded-[10px] bg-[var(--surface-strong,var(--muted))] text-[color:var(--command-muted-foreground,var(--foreground-subtle,var(--muted-foreground)))] hover:not-focus-within:not-has-[input:disabled]:not-has-[input[aria-invalid=true]]:ring-[var(--border-hover,var(--border))]",
          // cobalt-allow-focus-within -- text input
          "focus-within:not-has-[input:disabled]:not-has-[input[aria-invalid=true]]:ring-[var(--foreground-subtle,var(--ring))]",
          "data-[disabled=true]:not-data-[invalid=true]:bg-[var(--input-disabled-surface,var(--muted))] data-[disabled=true]:not-data-[invalid=true]:text-[color:var(--search-field-disabled-icon,var(--input-disabled-foreground,var(--muted-foreground)))]",
          "data-[invalid=true]:bg-[var(--input-invalid-surface,color-mix(in_srgb,var(--destructive)_15%,transparent))] data-[invalid=true]:text-destructive",
          size === "default" ? "h-9 pe-2 ps-1 py-2" : "h-8 pe-1.5 ps-1 py-2",
          shape === "pill" && "rounded-full",
          fieldClassName
        )}
      >
        <div className="flex w-full min-w-0 items-center gap-2">
          <div className="flex min-w-0 flex-1 items-center gap-0.5">
            {/* The icon and the light share one 20px box and cross-fade. */}
            <span aria-hidden="true" className="relative ms-1 size-5 shrink-0">
              <Icon
                className={cn(
                  "absolute inset-0 size-5 transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none",
                  variant === "muted" &&
                    !isInvalid &&
                    !disabled &&
                    "text-[color:var(--icon-foreground,var(--muted-foreground))]",
                  isLoading && "scale-75 opacity-0"
                )}
              />
              <span
                className={cn(
                  "absolute inset-0 flex items-center justify-center transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none",
                  // Hidden, the light stays mounted for the fade but stops turning.
                  !isLoading &&
                    "scale-75 opacity-0 [&_*]:[animation-play-state:paused]"
                )}
              >
                <Spinner aria-hidden={true} />
              </span>
            </span>
            <InputPrimitive
              {...inputProps}
              ref={inputRef}
              id={inputId}
              type={inputProps.type ?? "search"}
              data-slot="search-field-input"
              value={inputValue}
              disabled={disabled}
              readOnly={readOnly}
              required={required}
              aria-label={
                ariaLabel ??
                (!label && typeof placeholder === "string"
                  ? placeholder
                  : undefined)
              }
              aria-invalid={ariaInvalid}
              aria-describedby={
                [ariaDescribedBy, hint ? hintId : undefined]
                  .filter(Boolean)
                  .join(" ") || undefined
              }
              placeholder={placeholder}
              onChange={(event) => change(event.target.value)}
              onKeyDown={handleKeyDown}
              className={cn(
                "m-0 min-w-0 flex-1 border-0 bg-transparent p-0 ps-1 text-start font-sans text-sm leading-5 font-normal text-foreground outline-none focus:placeholder:text-foreground disabled:cursor-not-allowed disabled:text-[color:var(--search-field-disabled-text,var(--input-disabled-foreground,var(--muted-foreground)))] disabled:placeholder:text-[color:var(--search-field-disabled-text,var(--input-disabled-foreground,var(--muted-foreground)))] aria-invalid:placeholder:text-[color:var(--input-invalid-placeholder,var(--destructive))] [&::-webkit-search-cancel-button]:hidden",
                variant === "muted"
                  ? "placeholder:text-[color:var(--foreground-subtle,var(--muted-foreground))]"
                  : "placeholder:text-[color:var(--command-muted-foreground,var(--foreground-subtle,var(--muted-foreground)))]"
              )}
            />
          </div>
          <button
            type="button"
            data-slot="search-field-clear"
            aria-label={clearLabel}
            aria-hidden={isEmpty || undefined}
            tabIndex={-1}
            disabled={disabled}
            onMouseDown={(event) => event.preventDefault()}
            onClick={clear}
            className={cn(
              "me-0.5 inline-flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-full bg-[var(--trace-guide,color-mix(in_srgb,var(--foreground)_12%,transparent))] text-muted-foreground transition-[opacity,scale,color] duration-150 ease-out select-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none motion-reduce:transition-none",
              isEmpty || disabled
                ? "pointer-events-none scale-50 opacity-0"
                : "scale-100 opacity-100"
            )}
          >
            <svg
              width={6.8}
              height={6.8}
              viewBox="0 0 6.8 6.8"
              fill="none"
              aria-hidden="true"
            >
              <path
                d="M0.57 0.57L6.23 6.23"
                stroke="currentColor"
                strokeWidth={1.6}
                strokeLinecap="round"
              />
              <path
                d="M6.23 0.57L0.57 6.23"
                stroke="currentColor"
                strokeWidth={1.6}
                strokeLinecap="round"
              />
            </svg>
          </button>
        </div>
      </div>
      {hint ? (
        <p
          id={hintId}
          data-slot="search-field-hint"
          className={cn(
            "pt-px text-[12px] leading-4 font-medium tracking-[0.15px]",
            isInvalid
              ? "text-[color:var(--field-invalid,var(--destructive))]"
              : "text-muted-foreground"
          )}
        >
          {hint}
        </p>
      ) : null}
    </div>
  );
}

export { SearchField };