Skip to content
← Components

Search Input

Search Input for a useful, keyboard-friendly product interface.

Open ↗

Make it yours.

Install the editable source and its dependencies into your React + Tailwind project.

Scroll horizontally for long lines
pnpm dlx shadcn@4.0.8 add http://localhost:3000/r/search-input.json

Use it

Scroll horizontally for long lines
"use client";
import { useState } from "react";
import { SearchInput } from "@/components/jez-ui/ui/search-input";
import { Folder, ArrowUpRight } from "lucide-react";

export default function Example() {
  const [searchTerm, setSearchTerm] = useState("");
  const [notice, setNotice] = useState("");
  return (
    <>
      <div className="w-full max-w-sm">
        <div className="mb-7">
          <h3 className="text-2xl tracking-tight">Find the thing.</h3>
          <p className="mt-2 text-sm leading-relaxed text-muted-foreground">
            Projects, notes, and the details you nearly forgot.
          </p>
        </div>
        <div>
          <SearchInput
            value={searchTerm}
            onValueChange={setSearchTerm}
            placeholder="Search projects…"
          />
          <div className="mt-3 overflow-hidden rounded-lg border border-border">
            {["Website refresh", "Brand guidelines", "Component library"]
              .filter((t) => t.toLowerCase().includes(searchTerm.toLowerCase()))
              .map((t) => (
                <button
                  key={t}
                  onClick={() => setNotice(t + " opened.")}
                  className="flex w-full items-center gap-3 border-b border-border/50 px-3 py-3 text-left text-sm last:border-0 hover:bg-muted"
                >
                  <Folder
                    size={15}
                    className="text-muted-foreground"
                  />
                  {t}
                  <ArrowUpRight
                    size={13}
                    className="ml-auto text-muted-foreground"
                  />
                </button>
              ))}
            {!["Website refresh", "Brand guidelines", "Component library"].some(
              (t) => t.toLowerCase().includes(searchTerm.toLowerCase()),
            ) && (
              <p className="p-5 text-sm text-muted-foreground">
                No projects found.
              </p>
            )}
          </div>
        </div>
      </div>
      <p role="status">{notice}</p>
    </>
  );
}

Props & types

Scroll horizontally for long lines
Omit<
  React.ComponentProps<typeof Input>,
  "value" | "defaultValue" | "onChange"
> & {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  loading?: boolean;
  inputClassName?: string;
  clearLabel?: string;
}

Source

View 4 source files

registry/ui/search-input.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { Search, X, LoaderCircle } from "lucide-react";
import { Input } from "./input";
import { useControllable } from "./use-controllable";
import { cn } from "./utils";
export function SearchInput({
  value,
  defaultValue = "",
  onValueChange,
  className,
  inputClassName,
  ref: forwardedRef,
  clearLabel = "Clear search",
  loading = false,
  ...props
}: Omit<
  React.ComponentProps<typeof Input>,
  "value" | "defaultValue" | "onChange"
> & {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  loading?: boolean;
  inputClassName?: string;
  clearLabel?: string;
}) {
  const [query, setQuery] = useControllable(value, defaultValue, onValueChange);
  const ref = React.useRef<HTMLInputElement>(null);
  React.useImperativeHandle(forwardedRef, () => ref.current!, []);
  return (
    <div role="search" className={cn("group relative w-full", className)}>
      {loading ? (
        <LoaderCircle
          size={16}
          className="absolute left-3 top-3 animate-spin text-primary"
        />
      ) : (
        <Search
          size={16}
          className="pointer-events-none absolute left-3 top-3 text-muted-foreground transition-colors group-focus-within:text-primary"
        />
      )}
      <Input
        ref={ref}
        type="search"
        aria-label="Search"
        placeholder="Search…"
        {...props}
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        className={cn(
          "px-10 [&::-webkit-search-cancel-button]:appearance-none",
          inputClassName,
        )}
      />
      {query && (
        <button
          type="button"
          disabled={props.disabled}
          aria-label={clearLabel}
          onClick={() => {
            setQuery("");
            ref.current?.focus();
          }}
          className="absolute right-1 top-1 grid size-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
        >
          <X size={14} />
        </button>
      )}
    </div>
  );
}

registry/ui/input.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { cn } from "./utils";
export function Input({ className, ...props }: React.ComponentProps<"input">) {
  return (
    <input
      className={cn(
        "block h-10 w-full min-w-0 rounded-lg border border-border bg-background px-3 text-sm leading-normal shadow-[0_1px_2px_#00000004] transition-[border-color,box-shadow] placeholder:text-muted-foreground/75 hover:border-foreground/25 focus:border-primary focus:outline-none focus:ring-3 focus:ring-primary/10 aria-invalid:border-danger aria-invalid:ring-danger/10 disabled:cursor-not-allowed disabled:bg-muted/50 disabled:opacity-50",
        className,
      )}
      {...props}
    />
  );
}

registry/ui/utils.ts

Scroll horizontally for long lines
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...values: ClassValue[]) {
  return twMerge(clsx(values));
}

registry/ui/use-controllable.ts

Scroll horizontally for long lines
"use client";
import { useRef, useState, type SetStateAction } from "react";
/** Controlled or instance-local state. Functional updates also work before a rerender. */
export function useControllable<T>(
  value: T | undefined,
  initial: T,
  onChange?: (value: T) => void,
) {
  const [local, setLocal] = useState(initial);
  const current = useRef(value === undefined ? local : value);
  current.current = value === undefined ? local : value;
  return [
    current.current,
    (action: SetStateAction<T>) => {
      const next =
        typeof action === "function"
          ? (action as (previous: T) => T)(current.current)
          : action;
      current.current = next;
      if (value === undefined) setLocal(next);
      onChange?.(next);
    },
  ] as const;
}

Dependencies

lucide-react@0.577.0 · clsx@2.1.1 · tailwind-merge@3.5.0

Accessibility & behaviour

Provide meaningful labels and preserve keyboard focus styles. Check contrast when customising theme colours.

Read the accessibility and performance guide →