Web InnoventixFreeCode

Search Input

Original · free

search box with icon and clear

byWeb InnoventixReact + Tailwind
inpsearchinputs
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/inp-search.json
inp-search.tsx
"use client";

import { useId, useMemo, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/* ------------------------------------------------------------------ */
/* Types                                                              */
/* ------------------------------------------------------------------ */

type Suggestion = {
  id: string;
  title: string;
  kind: string;
  hint: string;
};

type AccentKey = "indigo" | "emerald";

type Accent = {
  ring: string;
  iconOn: string;
  optionOn: string;
  mark: string;
  chip: string;
  dot: string;
};

/* ------------------------------------------------------------------ */
/* Icons (inline SVG only)                                            */
/* ------------------------------------------------------------------ */

function SearchGlyph({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <circle cx={11} cy={11} r={7} />
      <path d="m20 20-3.2-3.2" />
    </svg>
  );
}

function ClearGlyph({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M6 6l12 12M18 6 6 18" />
    </svg>
  );
}

function ReturnGlyph({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M9 10 4 15l5 5" />
      <path d="M20 4v7a4 4 0 0 1-4 4H4" />
    </svg>
  );
}

function ClockGlyph({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <circle cx={12} cy={12} r={9} />
      <path d="M12 7v5l3.5 2" />
    </svg>
  );
}

/* ------------------------------------------------------------------ */
/* Static data                                                        */
/* ------------------------------------------------------------------ */

const LIBRARY: Suggestion[] = [
  { id: "s1", title: "Invoice INV-2043 — Northwind Traders", kind: "Invoice", hint: "Billing" },
  { id: "s2", title: "Q3 revenue forecast", kind: "Report", hint: "Analytics" },
  { id: "s3", title: "Rotate production API keys", kind: "Runbook", hint: "Security" },
  { id: "s4", title: "Invite Priya Menon to Engineering", kind: "Action", hint: "Team" },
  { id: "s5", title: "Deployment logs — api-gateway", kind: "Logs", hint: "Infra" },
  { id: "s6", title: "Customer: Aster Health Systems", kind: "Account", hint: "CRM" },
  { id: "s7", title: "Refund policy for annual plans", kind: "Doc", hint: "Help" },
  { id: "s8", title: "Webhook: order.completed", kind: "Integration", hint: "Developers" },
];

const RECENTS = ["billing settings", "api keys", "deploy logs", "team invites"];

const PAGES = [
  "Account & profile",
  "Billing and invoices",
  "API keys and tokens",
  "Team members and roles",
  "Notification preferences",
  "Security & two-factor auth",
  "Webhooks and events",
  "Data export",
  "Third-party integrations",
  "Danger zone",
];

const ACCENTS: Record<AccentKey, Accent> = {
  indigo: {
    ring:
      "focus-within:border-indigo-400 focus-within:ring-indigo-500/40 dark:focus-within:border-indigo-400/60",
    iconOn: "text-indigo-500 dark:text-indigo-400",
    optionOn:
      "bg-indigo-50 text-indigo-950 dark:bg-indigo-500/15 dark:text-indigo-50",
    mark: "bg-indigo-100 text-indigo-900 dark:bg-indigo-400/25 dark:text-indigo-50",
    chip:
      "border-indigo-200 text-indigo-700 hover:bg-indigo-50 focus-visible:ring-indigo-500/50 dark:border-indigo-400/25 dark:text-indigo-300 dark:hover:bg-indigo-500/10",
    dot: "bg-indigo-500 dark:bg-indigo-400",
  },
  emerald: {
    ring:
      "focus-within:border-emerald-400 focus-within:ring-emerald-500/40 dark:focus-within:border-emerald-400/60",
    iconOn: "text-emerald-600 dark:text-emerald-400",
    optionOn:
      "bg-emerald-50 text-emerald-950 dark:bg-emerald-500/15 dark:text-emerald-50",
    mark: "bg-emerald-100 text-emerald-900 dark:bg-emerald-400/25 dark:text-emerald-50",
    chip:
      "border-emerald-200 text-emerald-700 hover:bg-emerald-50 focus-visible:ring-emerald-500/50 dark:border-emerald-400/25 dark:text-emerald-300 dark:hover:bg-emerald-500/10",
    dot: "bg-emerald-500 dark:bg-emerald-400",
  },
};

/* ------------------------------------------------------------------ */
/* Helpers                                                            */
/* ------------------------------------------------------------------ */

function Highlight({ text, query, mark }: { text: string; query: string; mark: string }) {
  const q = query.trim();
  if (!q) return <>{text}</>;
  const at = text.toLowerCase().indexOf(q.toLowerCase());
  if (at === -1) return <>{text}</>;
  return (
    <>
      {text.slice(0, at)}
      <mark className={`rounded-[3px] px-px ${mark}`}>{text.slice(at, at + q.length)}</mark>
      {text.slice(at + q.length)}
    </>
  );
}

/* ------------------------------------------------------------------ */
/* Combobox — search with icon, clear button, keyboard suggestions    */
/* ------------------------------------------------------------------ */

function Combobox({
  label,
  placeholder,
  accent,
  shape,
  recents,
}: {
  label: string;
  placeholder: string;
  accent: AccentKey;
  shape: "xl" | "full";
  recents?: string[];
}) {
  const uid = useId();
  const listId = `${uid}-list`;
  const inputRef = useRef<HTMLInputElement>(null);
  const reduce = useReducedMotion();
  const a = ACCENTS[accent];

  const [query, setQuery] = useState("");
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(-1);

  const filtered = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (!q) return [];
    return LIBRARY.filter(
      (s) => s.title.toLowerCase().includes(q) || s.kind.toLowerCase().includes(q),
    ).slice(0, 6);
  }, [query]);

  const showPanel = open && (filtered.length > 0 || query.trim().length > 0);

  function commit(value: string) {
    setQuery(value);
    setOpen(false);
    setActive(-1);
    inputRef.current?.focus();
  }

  function clear() {
    setQuery("");
    setActive(-1);
    setOpen(false);
    inputRef.current?.focus();
  }

  function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
    if (e.key === "ArrowDown") {
      e.preventDefault();
      if (!open) setOpen(true);
      setActive((i) => (filtered.length ? Math.min(i + 1, filtered.length - 1) : -1));
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      setActive((i) => Math.max(i - 1, 0));
    } else if (e.key === "Enter") {
      if (active >= 0 && filtered[active]) {
        e.preventDefault();
        commit(filtered[active].title);
      }
    } else if (e.key === "Escape") {
      if (open) {
        e.preventDefault();
        setOpen(false);
        setActive(-1);
      } else if (query) {
        setQuery("");
      }
    } else if (e.key === "Home") {
      if (open && filtered.length) {
        e.preventDefault();
        setActive(0);
      }
    } else if (e.key === "End") {
      if (open && filtered.length) {
        e.preventDefault();
        setActive(filtered.length - 1);
      }
    }
  }

  const radius = shape === "full" ? "rounded-full" : "rounded-2xl";
  const panelRadius = shape === "full" ? "rounded-3xl" : "rounded-2xl";
  const activeId = active >= 0 ? `${uid}-opt-${active}` : undefined;
  const busy = query.trim().length > 0;

  return (
    <div className="relative">
      <label htmlFor={`${uid}-input`} className="sr-only">
        {label}
      </label>

      <div
        className={`group flex items-center gap-3 border bg-white px-4 py-3 shadow-sm ring-4 ring-transparent transition-colors ${radius} ${a.ring} border-slate-200 dark:border-zinc-700/70 dark:bg-zinc-900/70 ${
          shape === "full" ? "pl-5 pr-2" : ""
        }`}
      >
        <span className="relative flex h-5 w-5 shrink-0 items-center justify-center">
          <SearchGlyph
            className={`h-5 w-5 transition-colors ${
              busy ? a.iconOn : "text-slate-400 dark:text-zinc-500"
            }`}
          />
          {busy ? (
            <span
              className={`inpsrch-pulse absolute -right-0.5 -top-0.5 h-1.5 w-1.5 rounded-full ${a.dot}`}
            />
          ) : null}
        </span>

        <input
          ref={inputRef}
          id={`${uid}-input`}
          type="text"
          role="combobox"
          aria-expanded={showPanel}
          aria-controls={listId}
          aria-autocomplete="list"
          aria-activedescendant={activeId}
          autoComplete="off"
          spellCheck={false}
          value={query}
          placeholder={placeholder}
          onChange={(e) => {
            setQuery(e.target.value);
            setOpen(true);
            setActive(-1);
          }}
          onFocus={() => setOpen(true)}
          onBlur={() => setOpen(false)}
          onKeyDown={onKeyDown}
          className="min-w-0 flex-1 bg-transparent text-[15px] text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-zinc-50 dark:placeholder:text-zinc-500"
        />

        <AnimatePresence initial={false}>
          {query ? (
            <motion.button
              key="clear"
              type="button"
              onClick={clear}
              aria-label="Clear search"
              initial={reduce ? false : { opacity: 0, scale: 0.6 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
              transition={{ duration: reduce ? 0 : 0.14 }}
              className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-500/60 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
            >
              <ClearGlyph className="h-4 w-4" />
            </motion.button>
          ) : (
            <kbd
              key="hint"
              className="hidden select-none items-center gap-0.5 rounded-md border border-slate-200 bg-slate-50 px-1.5 py-0.5 font-mono text-[11px] font-medium text-slate-500 sm:flex dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400"
            >
              /
            </kbd>
          )}
        </AnimatePresence>
      </div>

      <AnimatePresence>
        {showPanel ? (
          <motion.div
            initial={reduce ? false : { opacity: 0, y: -6, scale: 0.985 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.985 }}
            transition={{ duration: reduce ? 0 : 0.16, ease: [0.16, 1, 0.3, 1] }}
            className={`absolute left-0 right-0 top-[calc(100%+8px)] z-20 overflow-hidden border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-zinc-700/70 dark:bg-zinc-900 dark:shadow-black/40 ${panelRadius}`}
          >
            {filtered.length > 0 ? (
              <ul id={listId} role="listbox" aria-label={label} className="max-h-72 overflow-y-auto py-1.5">
                {filtered.map((s, i) => {
                  const on = i === active;
                  return (
                    <li key={s.id} role="option" id={`${uid}-opt-${i}`} aria-selected={on}>
                      <button
                        type="button"
                        tabIndex={-1}
                        onMouseDown={(e) => e.preventDefault()}
                        onMouseEnter={() => setActive(i)}
                        onClick={() => commit(s.title)}
                        className={`flex w-full items-center gap-3 px-3 py-2 text-left transition-colors ${
                          on ? a.optionOn : "text-slate-700 dark:text-zinc-200"
                        }`}
                      >
                        <span
                          className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border text-slate-500 ${
                            on
                              ? "border-transparent bg-white/70 dark:bg-white/5"
                              : "border-slate-200 bg-slate-50 dark:border-zinc-700 dark:bg-zinc-800"
                          }`}
                        >
                          <SearchGlyph className="h-4 w-4" />
                        </span>
                        <span className="min-w-0 flex-1">
                          <span className="block truncate text-sm font-medium">
                            <Highlight text={s.title} query={query} mark={a.mark} />
                          </span>
                          <span className="block truncate text-xs text-slate-400 dark:text-zinc-500">
                            {s.kind} · {s.hint}
                          </span>
                        </span>
                        {on ? (
                          <ReturnGlyph className="h-4 w-4 shrink-0 opacity-70" />
                        ) : null}
                      </button>
                    </li>
                  );
                })}
              </ul>
            ) : (
              <div className="px-4 py-6 text-center">
                <p className="text-sm font-medium text-slate-700 dark:text-zinc-200">
                  No matches for &ldquo;{query.trim()}&rdquo;
                </p>
                <p className="mt-1 text-xs text-slate-400 dark:text-zinc-500">
                  Try a customer name, invoice number, or runbook.
                </p>
              </div>
            )}

            {recents && filtered.length > 0 ? (
              <div className="flex flex-wrap items-center gap-1.5 border-t border-slate-100 px-3 py-2.5 dark:border-zinc-800">
                <ClockGlyph className="h-3.5 w-3.5 text-slate-400 dark:text-zinc-500" />
                {recents.map((r) => (
                  <button
                    key={r}
                    type="button"
                    tabIndex={-1}
                    onMouseDown={(e) => e.preventDefault()}
                    onClick={() => commit(r)}
                    className={`rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 ${a.chip}`}
                  >
                    {r}
                  </button>
                ))}
              </div>
            ) : null}
          </motion.div>
        ) : null}
      </AnimatePresence>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Inline list filter — search filters a visible list, live count     */
/* ------------------------------------------------------------------ */

function ListFilter() {
  const uid = useId();
  const inputRef = useRef<HTMLInputElement>(null);
  const [query, setQuery] = useState("settings");

  const results = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (!q) return PAGES;
    return PAGES.filter((p) => p.toLowerCase().includes(q));
  }, [query]);

  const mark = "bg-violet-100 text-violet-900 dark:bg-violet-400/25 dark:text-violet-50";

  return (
    <div>
      <label
        htmlFor={`${uid}-input`}
        className="mb-1.5 block text-xs font-medium text-slate-500 dark:text-zinc-400"
      >
        Filter settings
      </label>

      <div className="flex items-center gap-2 border-b-2 border-slate-200 pb-1.5 transition-colors focus-within:border-violet-400 dark:border-zinc-700 dark:focus-within:border-violet-400/70">
        <SearchGlyph
          className={`h-4 w-4 shrink-0 ${
            query ? "text-violet-500 dark:text-violet-400" : "text-slate-400 dark:text-zinc-500"
          }`}
        />
        <input
          ref={inputRef}
          id={`${uid}-input`}
          type="text"
          value={query}
          autoComplete="off"
          spellCheck={false}
          placeholder="Type to filter…"
          aria-describedby={`${uid}-count`}
          onChange={(e) => setQuery(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === "Escape" && query) {
              e.preventDefault();
              setQuery("");
            }
          }}
          className="min-w-0 flex-1 bg-transparent text-[15px] text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-zinc-50 dark:placeholder:text-zinc-500"
        />
        {query ? (
          <button
            type="button"
            aria-label="Clear filter"
            onClick={() => {
              setQuery("");
              inputRef.current?.focus();
            }}
            className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/60 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
          >
            <ClearGlyph className="h-3.5 w-3.5" />
          </button>
        ) : null}
      </div>

      <p id={`${uid}-count`} aria-live="polite" className="mt-2 text-xs text-slate-400 dark:text-zinc-500">
        {results.length} of {PAGES.length} pages
      </p>

      <ul className="mt-2 max-h-48 space-y-0.5 overflow-y-auto pr-1">
        {results.length > 0 ? (
          results.map((p) => (
            <li key={p}>
              <div className="rounded-lg px-2.5 py-2 text-sm text-slate-700 transition-colors hover:bg-slate-50 dark:text-zinc-200 dark:hover:bg-zinc-800/60">
                <Highlight text={p} query={query} mark={mark} />
              </div>
            </li>
          ))
        ) : (
          <li className="px-2.5 py-3 text-sm text-slate-400 dark:text-zinc-500">
            Nothing matches that filter.
          </li>
        )}
      </ul>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Showcase                                                           */
/* ------------------------------------------------------------------ */

export default function InpSearch() {
  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-slate-100 px-6 py-20 sm:px-10 dark:from-zinc-950 dark:to-zinc-900">
      <style>{`
        @keyframes inpsrch-pulse {
          0%, 100% { opacity: .4; transform: scale(1); }
          50% { opacity: 1; transform: scale(1.5); }
        }
        @keyframes inpsrch-sheen {
          0% { background-position: 0% 50%; }
          100% { background-position: 200% 50%; }
        }
        .inpsrch-pulse { animation: inpsrch-pulse 1.6s ease-in-out infinite; }
        .inpsrch-sheen {
          background-size: 200% 100%;
          animation: inpsrch-sheen 3.2s linear infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .inpsrch-pulse, .inpsrch-sheen { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 opacity-[0.5] [mask-image:radial-gradient(ellipse_at_top,black,transparent_70%)]"
        style={{
          backgroundImage:
            "linear-gradient(to right, rgba(100,116,139,0.12) 1px, transparent 1px), linear-gradient(to bottom, rgba(100,116,139,0.12) 1px, transparent 1px)",
          backgroundSize: "44px 44px",
        }}
      />

      <div className="relative mx-auto max-w-2xl">
        <header className="mb-12 text-center">
          <span className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium text-slate-500 backdrop-blur dark:border-zinc-700 dark:bg-zinc-900/60 dark:text-zinc-400">
            <SearchGlyph className="h-3.5 w-3.5" />
            inp-search
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-zinc-50">
            Search fields that feel instant
          </h2>
          <p className="mx-auto mt-3 max-w-md text-[15px] leading-relaxed text-slate-500 dark:text-zinc-400">
            Icon, clearable input, keyboard-navigable suggestions and live filtering —
            accessible in light and dark.
          </p>
          <div className="inpsrch-sheen mx-auto mt-6 h-px w-40 bg-gradient-to-r from-transparent via-indigo-400 to-transparent" />
        </header>

        <div className="space-y-10">
          <div>
            <p className="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-zinc-500">
              Global search · combobox
            </p>
            <Combobox
              label="Search accounts, invoices and runbooks"
              placeholder="Search accounts, invoices, runbooks…"
              accent="indigo"
              shape="xl"
              recents={RECENTS}
            />
            <p className="mt-2 pl-1 text-xs text-slate-400 dark:text-zinc-500">
              Try typing “invoice” or “api”, then use ↑ ↓ and Enter.
            </p>
          </div>

          <div>
            <p className="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-zinc-500">
              Compact · pill shape
            </p>
            <Combobox
              label="Quick find"
              placeholder="Quick find…"
              accent="emerald"
              shape="full"
            />
          </div>

          <div className="rounded-2xl border border-slate-200 bg-white/70 p-5 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/50">
            <p className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-zinc-500">
              Inline filter · live results
            </p>
            <ListFilter />
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →