Web InnoventixFreeCode

Search Suggest Input

Original · free

search with a live suggestions dropdown

byWeb InnoventixReact + Tailwind
inpsearchsuggestinputs
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-suggest.json
inp-search-suggest.tsx
"use client";

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

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

type IconKind =
  | "book"
  | "user"
  | "card"
  | "link"
  | "code"
  | "person"
  | "bolt"
  | "pin";

type Accent = "indigo" | "emerald";

interface Item {
  id: string;
  label: string;
  category: string;
  scope: string;
  hint: string;
  kind: IconKind;
}

interface AccentTokens {
  ring: string;
  focusWithin: string;
  caret: string;
  optActive: string;
  optBar: string;
  iconTint: string;
  mark: string;
  scopeActive: string;
  pill: string;
  spin: string;
  chipHover: string;
}

/* ------------------------------------------------------------------ */
/* Static accent maps (kept literal so Tailwind never purges them)    */
/* ------------------------------------------------------------------ */

const ACCENTS: Record<Accent, AccentTokens> = {
  indigo: {
    ring: "focus-visible:ring-indigo-500/60 dark:focus-visible:ring-indigo-400/60",
    focusWithin:
      "focus-within:border-indigo-400 focus-within:ring-4 focus-within:ring-indigo-500/15 dark:focus-within:border-indigo-500/70 dark:focus-within:ring-indigo-400/15",
    caret: "caret-indigo-600 dark:caret-indigo-400",
    optActive: "bg-indigo-50 dark:bg-indigo-500/10",
    optBar: "bg-indigo-500 dark:bg-indigo-400",
    iconTint: "text-indigo-600 dark:text-indigo-400",
    mark: "bg-indigo-100 text-indigo-900 dark:bg-indigo-400/25 dark:text-indigo-50",
    scopeActive: "bg-indigo-600 text-white shadow-sm dark:bg-indigo-500",
    pill: "border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-400/30 dark:bg-indigo-400/10 dark:text-indigo-200",
    spin: "text-indigo-500 dark:text-indigo-400",
    chipHover:
      "hover:border-indigo-300 hover:text-indigo-700 dark:hover:border-indigo-400/40 dark:hover:text-indigo-200",
  },
  emerald: {
    ring: "focus-visible:ring-emerald-500/60 dark:focus-visible:ring-emerald-400/60",
    focusWithin:
      "focus-within:border-emerald-400 focus-within:ring-4 focus-within:ring-emerald-500/15 dark:focus-within:border-emerald-500/70 dark:focus-within:ring-emerald-400/15",
    caret: "caret-emerald-600 dark:caret-emerald-400",
    optActive: "bg-emerald-50 dark:bg-emerald-500/10",
    optBar: "bg-emerald-500 dark:bg-emerald-400",
    iconTint: "text-emerald-600 dark:text-emerald-400",
    mark: "bg-emerald-100 text-emerald-900 dark:bg-emerald-400/25 dark:text-emerald-50",
    scopeActive: "bg-emerald-600 text-white shadow-sm dark:bg-emerald-500",
    pill: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-400/30 dark:bg-emerald-400/10 dark:text-emerald-200",
    spin: "text-emerald-500 dark:text-emerald-400",
    chipHover:
      "hover:border-emerald-300 hover:text-emerald-700 dark:hover:border-emerald-400/40 dark:hover:text-emerald-200",
  },
};

/* ------------------------------------------------------------------ */
/* Data                                                               */
/* ------------------------------------------------------------------ */

const WORKSPACE_ITEMS: Item[] = [
  { id: "d-start", label: "Getting started guide", category: "Guides", scope: "Docs", hint: "docs/start", kind: "book" },
  { id: "d-invite", label: "Invite teammates", category: "Guides", scope: "Docs", hint: "docs/team", kind: "book" },
  { id: "d-shortcuts", label: "Keyboard shortcuts", category: "Guides", scope: "Docs", hint: "docs/shortcuts", kind: "book" },
  { id: "d-pass", label: "Reset your password", category: "Account", scope: "Docs", hint: "docs/account", kind: "user" },
  { id: "d-2fa", label: "Two-factor authentication", category: "Account", scope: "Docs", hint: "docs/security", kind: "user" },
  { id: "d-bill", label: "Update billing details", category: "Billing", scope: "Docs", hint: "docs/billing", kind: "card" },
  { id: "d-inv", label: "Download past invoices", category: "Billing", scope: "Docs", hint: "docs/billing", kind: "card" },
  { id: "d-slack", label: "Connect Slack", category: "Integrations", scope: "Docs", hint: "docs/integrations", kind: "link" },
  { id: "d-hooks", label: "Webhooks reference", category: "Developers", scope: "Docs", hint: "docs/api", kind: "code" },
  { id: "p-ava", label: "Ava Chen", category: "Design", scope: "People", hint: "Product Designer", kind: "person" },
  { id: "p-lena", label: "Lena Kowalski", category: "Design", scope: "People", hint: "Design Systems", kind: "person" },
  { id: "p-marcus", label: "Marcus Reed", category: "Engineering", scope: "People", hint: "Frontend Engineer", kind: "person" },
  { id: "p-priya", label: "Priya Nair", category: "Engineering", scope: "People", hint: "Staff Engineer", kind: "person" },
  { id: "p-diego", label: "Diego Santos", category: "Support", scope: "People", hint: "Support Lead", kind: "person" },
  { id: "a-new", label: "Create new project", category: "Actions", scope: "Actions", hint: "Shift N", kind: "bolt" },
  { id: "a-invite", label: "Invite a teammate", category: "Actions", scope: "Actions", hint: "Shift I", kind: "bolt" },
  { id: "a-billing", label: "Open billing settings", category: "Actions", scope: "Actions", hint: "Settings", kind: "bolt" },
  { id: "a-trial", label: "Start a free trial", category: "Actions", scope: "Actions", hint: "Upgrade", kind: "bolt" },
];

const CITY_ITEMS: Item[] = [
  { id: "c-sf", label: "San Francisco", category: "Americas", scope: "Cities", hint: "PST · UTC-8", kind: "pin" },
  { id: "c-ny", label: "New York", category: "Americas", scope: "Cities", hint: "EST · UTC-5", kind: "pin" },
  { id: "c-sp", label: "São Paulo", category: "Americas", scope: "Cities", hint: "BRT · UTC-3", kind: "pin" },
  { id: "c-lon", label: "London", category: "Europe", scope: "Cities", hint: "GMT · UTC+0", kind: "pin" },
  { id: "c-ber", label: "Berlin", category: "Europe", scope: "Cities", hint: "CET · UTC+1", kind: "pin" },
  { id: "c-lag", label: "Lagos", category: "Africa", scope: "Cities", hint: "WAT · UTC+1", kind: "pin" },
  { id: "c-mum", label: "Mumbai", category: "Asia", scope: "Cities", hint: "IST · UTC+5:30", kind: "pin" },
  { id: "c-sin", label: "Singapore", category: "Asia", scope: "Cities", hint: "SGT · UTC+8", kind: "pin" },
  { id: "c-tok", label: "Tokyo", category: "Asia", scope: "Cities", hint: "JST · UTC+9", kind: "pin" },
  { id: "c-syd", label: "Sydney", category: "Oceania", scope: "Cities", hint: "AEDT · UTC+11", kind: "pin" },
];

const WORKSPACE_SCOPES = ["All", "Docs", "People", "Actions"] as const;
const WORKSPACE_RECENT = ["Reset password", "Webhooks", "Ava Chen"] as const;

/* ------------------------------------------------------------------ */
/* Icons (inline SVG, no libraries)                                   */
/* ------------------------------------------------------------------ */

function Svg({ children, className }: { children: ReactNode; className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      {children}
    </svg>
  );
}

function ResultIcon({ kind, className }: { kind: IconKind; className?: string }) {
  switch (kind) {
    case "book":
      return (
        <Svg className={className}>
          <path d="M6 4h11a1 1 0 0 1 1 1v14H7a3 3 0 0 0-3 3V7a3 3 0 0 1 3-3Z" />
          <path d="M4 19a3 3 0 0 1 3-3h11" />
        </Svg>
      );
    case "user":
      return (
        <Svg className={className}>
          <path d="M12 3 5 6v5c0 4.2 2.9 7.4 7 8.7 4.1-1.3 7-4.5 7-8.7V6l-7-3Z" />
          <path d="m9 11.5 2 2 3.5-3.8" />
        </Svg>
      );
    case "card":
      return (
        <Svg className={className}>
          <rect x="3" y="5" width="18" height="14" rx="2" />
          <path d="M3 10h18" />
        </Svg>
      );
    case "link":
      return (
        <Svg className={className}>
          <path d="M10 14a4 4 0 0 0 5.7 0l2.3-2.3a4 4 0 0 0-5.7-5.7L11 7.3" />
          <path d="M14 10a4 4 0 0 0-5.7 0L6 12.3a4 4 0 0 0 5.7 5.7L13 16.7" />
        </Svg>
      );
    case "code":
      return (
        <Svg className={className}>
          <path d="m9 8-4 4 4 4" />
          <path d="m15 8 4 4-4 4" />
        </Svg>
      );
    case "person":
      return (
        <Svg className={className}>
          <circle cx="12" cy="8" r="3.5" />
          <path d="M5.5 20a6.5 6.5 0 0 1 13 0" />
        </Svg>
      );
    case "bolt":
      return (
        <Svg className={className}>
          <path d="M13 3 5 13h5l-1 8 8-11h-5l1-7Z" />
        </Svg>
      );
    case "pin":
      return (
        <Svg className={className}>
          <path d="M12 21s6.5-5.8 6.5-10.5a6.5 6.5 0 1 0-13 0C5.5 15.2 12 21 12 21Z" />
          <circle cx="12" cy="10.5" r="2.3" />
        </Svg>
      );
  }
}

function SearchIcon({ className }: { className?: string }) {
  return (
    <Svg className={className}>
      <circle cx="11" cy="11" r="7" />
      <path d="m20 20-3.4-3.4" />
    </Svg>
  );
}

function ClearIcon({ className }: { className?: string }) {
  return (
    <Svg className={className}>
      <path d="M7 7l10 10M17 7 7 17" />
    </Svg>
  );
}

function ClockIcon({ className }: { className?: string }) {
  return (
    <Svg className={className}>
      <circle cx="12" cy="12" r="8.5" />
      <path d="M12 7.5V12l3 2" />
    </Svg>
  );
}

function Spinner({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      className={`iss-spin ${className ?? ""}`}
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth={2} className="opacity-20" />
      <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
    </svg>
  );
}

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

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

/* ------------------------------------------------------------------ */
/* Search field (accessible combobox)                                 */
/* ------------------------------------------------------------------ */

interface SearchFieldProps {
  label: string;
  hint: string;
  placeholder: string;
  items: Item[];
  accent: Accent;
  emptyHint: string;
  scopes?: readonly string[];
  recent?: readonly string[];
  hotkey?: boolean;
}

function SearchField({
  label,
  hint,
  placeholder,
  items,
  accent,
  emptyHint,
  scopes,
  recent,
  hotkey = false,
}: SearchFieldProps) {
  const uid = useId();
  const inputId = `${uid}-input`;
  const listboxId = `${uid}-listbox`;
  const statusId = `${uid}-status`;
  const optionId = (i: number) => `${uid}-opt-${i}`;

  const prefersReduced = useReducedMotion();
  const tokens = ACCENTS[accent];

  const [query, setQuery] = useState("");
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(-1);
  const [loading, setLoading] = useState(false);
  const [selected, setSelected] = useState<Item | null>(null);
  const [scope, setScope] = useState<string>(scopes ? scopes[0] : "All");

  const containerRef = useRef<HTMLDivElement | null>(null);
  const inputRef = useRef<HTMLInputElement | null>(null);
  const scopeRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const filtered = useMemo(() => {
    const base =
      scopes && scope !== scopes[0] ? items.filter((i) => i.scope === scope) : items;
    const q = query.trim().toLowerCase();
    if (!q) return base;
    return base.filter(
      (i) =>
        i.label.toLowerCase().includes(q) ||
        i.category.toLowerCase().includes(q) ||
        i.hint.toLowerCase().includes(q),
    );
  }, [items, scopes, scope, query]);

  const groups = useMemo(() => {
    const map = new Map<string, Item[]>();
    for (const item of filtered) {
      const arr = map.get(item.category) ?? [];
      arr.push(item);
      map.set(item.category, arr);
    }
    return Array.from(map, ([category, list]) => ({ category, list }));
  }, [filtered]);

  const indexById = useMemo(() => {
    const m = new Map<string, number>();
    filtered.forEach((it, i) => m.set(it.id, i));
    return m;
  }, [filtered]);

  const showRecent = query.trim() === "" && !!recent && recent.length > 0;

  /* simulated live fetch — purely cosmetic, keeps results visible */
  useEffect(() => {
    if (query.trim() === "") {
      setLoading(false);
      return;
    }
    setLoading(true);
    const t = setTimeout(() => setLoading(false), 240);
    return () => clearTimeout(t);
  }, [query]);

  /* keep active index in range */
  useEffect(() => {
    if (active >= filtered.length) setActive(-1);
  }, [filtered.length, active]);

  /* scroll the active option into view */
  useEffect(() => {
    if (active < 0) return;
    const el = document.getElementById(optionId(active));
    el?.scrollIntoView({ block: "nearest" });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [active]);

  /* close on outside pointer */
  useEffect(() => {
    if (!open) return;
    function onDown(e: PointerEvent) {
      if (!containerRef.current?.contains(e.target as Node)) {
        setOpen(false);
        setActive(-1);
      }
    }
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
  }, [open]);

  /* "/" hotkey to focus */
  useEffect(() => {
    if (!hotkey) return;
    function onKey(e: KeyboardEvent) {
      if (e.key !== "/") return;
      const el = document.activeElement;
      const typing =
        el instanceof HTMLInputElement ||
        el instanceof HTMLTextAreaElement ||
        (el instanceof HTMLElement && el.isContentEditable);
      if (typing) return;
      e.preventDefault();
      inputRef.current?.focus();
      setOpen(true);
    }
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [hotkey]);

  function commit(item: Item) {
    setSelected(item);
    setQuery(item.label);
    setOpen(false);
    setActive(-1);
    inputRef.current?.focus();
  }

  function onInputKeyDown(e: ReactKeyboardEvent<HTMLInputElement>) {
    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        if (!open) {
          setOpen(true);
          return;
        }
        if (filtered.length) setActive((a) => (a + 1) % filtered.length);
        break;
      case "ArrowUp":
        e.preventDefault();
        if (!open) {
          setOpen(true);
          return;
        }
        if (filtered.length) setActive((a) => (a <= 0 ? filtered.length - 1 : a - 1));
        break;
      case "Enter": {
        if (!open) break;
        const target = active >= 0 ? filtered[active] : filtered[0];
        if (target) {
          e.preventDefault();
          commit(target);
        }
        break;
      }
      case "Escape":
        if (open) {
          e.preventDefault();
          setOpen(false);
          setActive(-1);
        } else if (query) {
          setQuery("");
        }
        break;
      case "Home":
        if (open && filtered.length) {
          e.preventDefault();
          setActive(0);
        }
        break;
      case "End":
        if (open && filtered.length) {
          e.preventDefault();
          setActive(filtered.length - 1);
        }
        break;
      case "Tab":
        setOpen(false);
        break;
    }
  }

  function pickScope(next: string, focusIndex: number) {
    setScope(next);
    setActive(-1);
    setOpen(true);
    scopeRefs.current[focusIndex]?.focus();
  }

  return (
    <div ref={containerRef} className="relative">
      <div className="flex items-baseline justify-between gap-3">
        <label
          htmlFor={inputId}
          className="text-sm font-medium text-slate-700 dark:text-slate-200"
        >
          {label}
        </label>
        <span className="text-xs text-slate-400 dark:text-slate-500">{hint}</span>
      </div>

      {scopes && (
        <div
          role="radiogroup"
          aria-label={`${label} scope`}
          className="mt-3 inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-100/70 p-1 dark:border-slate-800 dark:bg-slate-800/60"
        >
          {scopes.map((s, i) => {
            const checked = scope === s;
            return (
              <button
                key={s}
                ref={(node) => {
                  scopeRefs.current[i] = node;
                }}
                type="button"
                role="radio"
                aria-checked={checked}
                tabIndex={checked ? 0 : -1}
                onClick={() => pickScope(s, i)}
                onKeyDown={(e) => {
                  if (e.key === "ArrowRight" || e.key === "ArrowDown") {
                    e.preventDefault();
                    const n = (i + 1) % scopes.length;
                    pickScope(scopes[n], n);
                  } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
                    e.preventDefault();
                    const n = (i - 1 + scopes.length) % scopes.length;
                    pickScope(scopes[n], n);
                  } else if (e.key === "Home") {
                    e.preventDefault();
                    pickScope(scopes[0], 0);
                  } else if (e.key === "End") {
                    e.preventDefault();
                    pickScope(scopes[scopes.length - 1], scopes.length - 1);
                  }
                }}
                className={`rounded-full px-3.5 py-1 text-xs font-semibold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${tokens.ring} ${
                  checked
                    ? tokens.scopeActive
                    : "text-slate-600 hover:bg-white hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-700/60 dark:hover:text-white"
                }`}
              >
                {s}
              </button>
            );
          })}
        </div>
      )}

      {/* input */}
      <div
        className={`mt-3 flex items-center gap-2.5 rounded-xl border border-slate-300 bg-white px-3.5 transition-shadow dark:border-slate-700 dark:bg-slate-900 ${tokens.focusWithin}`}
      >
        <SearchIcon className="h-[18px] w-[18px] shrink-0 text-slate-400 dark:text-slate-500" />
        <input
          id={inputId}
          ref={inputRef}
          type="text"
          role="combobox"
          inputMode="search"
          autoComplete="off"
          spellCheck={false}
          aria-expanded={open}
          aria-controls={listboxId}
          aria-autocomplete="list"
          aria-haspopup="listbox"
          aria-describedby={statusId}
          aria-activedescendant={open && active >= 0 ? optionId(active) : undefined}
          value={query}
          placeholder={placeholder}
          onChange={(e) => {
            setQuery(e.target.value);
            setOpen(true);
            setActive(-1);
            setSelected(null);
          }}
          onFocus={() => setOpen(true)}
          onKeyDown={onInputKeyDown}
          className={`h-12 w-full bg-transparent text-[15px] text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-slate-50 dark:placeholder:text-slate-500 ${tokens.caret}`}
        />
        {loading && <Spinner className={`h-[18px] w-[18px] shrink-0 ${tokens.spin}`} />}
        {query && !loading && (
          <button
            type="button"
            aria-label="Clear search"
            onClick={() => {
              setQuery("");
              setSelected(null);
              setActive(-1);
              setOpen(true);
              inputRef.current?.focus();
            }}
            className={`grid h-6 w-6 shrink-0 place-items-center rounded-md text-slate-400 outline-none transition-colors hover:bg-slate-100 hover:text-slate-600 focus-visible:ring-2 dark:hover:bg-slate-800 dark:hover:text-slate-200 ${tokens.ring}`}
          >
            <ClearIcon className="h-4 w-4" />
          </button>
        )}
      </div>

      {/* selected confirmation */}
      {selected && !open && (
        <div className="mt-3 flex items-center gap-2 text-sm">
          <span
            className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium ${tokens.pill}`}
          >
            <ResultIcon kind={selected.kind} className="h-3.5 w-3.5" />
            {selected.label}
          </span>
          <span className="text-slate-400 dark:text-slate-500">selected</span>
        </div>
      )}

      <span id={statusId} role="status" aria-live="polite" className="sr-only">
        {open
          ? `${filtered.length} suggestion${filtered.length === 1 ? "" : "s"} available`
          : ""}
      </span>

      {/* dropdown */}
      <AnimatePresence initial={false}>
        {open && (
          <motion.div
            key="panel"
            initial={prefersReduced ? { opacity: 0 } : { opacity: 0, y: -6 }}
            animate={{ opacity: 1, y: 0 }}
            exit={prefersReduced ? { opacity: 0 } : { opacity: 0, y: -6 }}
            transition={{ duration: prefersReduced ? 0.001 : 0.16, ease: "easeOut" }}
            className="absolute left-0 right-0 z-30 mt-2 overflow-hidden rounded-xl border border-slate-200 bg-white shadow-xl shadow-slate-900/10 dark:border-slate-700 dark:bg-slate-900 dark:shadow-black/40"
          >
            {showRecent && (
              <div className="flex flex-wrap items-center gap-2 border-b border-slate-100 px-3 py-2.5 dark:border-slate-800">
                <span className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-400 dark:text-slate-500">
                  <ClockIcon className="h-3.5 w-3.5" />
                  Recent
                </span>
                {recent!.map((r) => (
                  <button
                    key={r}
                    type="button"
                    onClick={() => {
                      setQuery(r);
                      setActive(-1);
                      inputRef.current?.focus();
                    }}
                    className={`rounded-full border border-slate-200 px-2.5 py-0.5 text-xs text-slate-600 outline-none transition-colors focus-visible:ring-2 dark:border-slate-700 dark:text-slate-300 ${tokens.chipHover} ${tokens.ring}`}
                  >
                    {r}
                  </button>
                ))}
              </div>
            )}

            <ul
              id={listboxId}
              role="listbox"
              aria-label={`${label} suggestions`}
              className="iss-fade max-h-72 overflow-y-auto overscroll-contain py-1.5"
            >
              {filtered.length === 0 ? (
                <li role="presentation" className="px-4 py-8 text-center">
                  <p className="text-sm font-medium text-slate-700 dark:text-slate-200">
                    No matches for &ldquo;{query.trim()}&rdquo;
                  </p>
                  <p className="mt-1 text-xs text-slate-400 dark:text-slate-500">{emptyHint}</p>
                </li>
              ) : (
                groups.map((group) => (
                  <li key={group.category} role="presentation">
                    <p className="px-3.5 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500">
                      {group.category}
                    </p>
                    <ul role="presentation">
                      {group.list.map((item) => {
                        const i = indexById.get(item.id) ?? -1;
                        const isActive = active === i;
                        return (
                          <li
                            key={item.id}
                            id={optionId(i)}
                            role="option"
                            aria-selected={isActive}
                            data-active={isActive}
                            onMouseEnter={() => setActive(i)}
                            onMouseDown={(e) => e.preventDefault()}
                            onClick={() => commit(item)}
                            className={`relative mx-1.5 flex cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 ${
                              isActive ? tokens.optActive : ""
                            }`}
                          >
                            {isActive && (
                              <span
                                aria-hidden="true"
                                className={`absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-full ${tokens.optBar}`}
                              />
                            )}
                            <span
                              className={`grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-slate-100 dark:bg-slate-800 ${
                                isActive ? tokens.iconTint : "text-slate-500 dark:text-slate-400"
                              }`}
                            >
                              <ResultIcon kind={item.kind} className="h-[18px] w-[18px]" />
                            </span>
                            <span className="min-w-0 flex-1">
                              <span className="block truncate text-sm text-slate-800 dark:text-slate-100">
                                {highlight(item.label, query, tokens.mark)}
                              </span>
                              <span className="block truncate text-xs text-slate-400 dark:text-slate-500">
                                {item.hint}
                              </span>
                            </span>
                            {isActive && (
                              <span className="hidden shrink-0 items-center gap-1 text-[11px] text-slate-400 dark:text-slate-500 sm:flex">
                                <kbd className="rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 font-sans dark:border-slate-600 dark:bg-slate-800">
                                  ↵
                                </kbd>
                                open
                              </span>
                            )}
                          </li>
                        );
                      })}
                    </ul>
                  </li>
                ))
              )}
            </ul>

            <div className="flex items-center justify-between gap-3 border-t border-slate-100 px-3.5 py-2 text-[11px] text-slate-400 dark:border-slate-800 dark:text-slate-500">
              <span>
                {filtered.length} result{filtered.length === 1 ? "" : "s"}
              </span>
              <span className="hidden items-center gap-2 sm:flex">
                <span className="inline-flex items-center gap-1">
                  <kbd className="rounded border border-slate-300 bg-slate-50 px-1 py-0.5 font-sans dark:border-slate-600 dark:bg-slate-800">
                    ↑
                  </kbd>
                  <kbd className="rounded border border-slate-300 bg-slate-50 px-1 py-0.5 font-sans dark:border-slate-600 dark:bg-slate-800">
                    ↓
                  </kbd>
                  navigate
                </span>
                <span className="inline-flex items-center gap-1">
                  <kbd className="rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 font-sans dark:border-slate-600 dark:bg-slate-800">
                    esc
                  </kbd>
                  close
                </span>
              </span>
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

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

const STYLES = `
@keyframes iss_spin { to { transform: rotate(360deg); } }
@keyframes iss_fadein { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: translateY(0); } }
.iss-spin { animation: iss_spin 0.7s linear infinite; transform-origin: center; }
.iss-fade { animation: iss_fadein 0.22s ease-out both; }
@media (prefers-reduced-motion: reduce) {
  .iss-spin { animation: none; }
  .iss-fade { animation: none; }
}
`;

export default function SearchSuggest() {
  return (
    <section className="relative w-full bg-slate-50 px-4 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-50 sm:py-28">
      <style>{STYLES}</style>

      <div className="mx-auto max-w-xl">
        <header>
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Live suggestions
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
            Search that answers as you type
          </h2>
          <p className="mt-3 text-[15px] leading-relaxed text-slate-500 dark:text-slate-400">
            A fully keyboard-navigable combobox: arrow keys to move, Enter to open, Escape to
            dismiss. Filter by scope, browse recents, and see matches highlighted in real time.
          </p>
        </header>

        {/* Variant 1 — full workspace search with scopes + recents */}
        <div className="relative z-20 mt-10 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
          <SearchField
            label="Search the workspace"
            hint="Press / to focus"
            placeholder="Search docs, people, and actions…"
            items={WORKSPACE_ITEMS}
            accent="indigo"
            scopes={WORKSPACE_SCOPES}
            recent={WORKSPACE_RECENT}
            emptyHint="Try a teammate's name, a doc title, or an action."
            hotkey
          />
        </div>

        {/* Variant 2 — compact single-purpose picker */}
        <div className="relative z-10 mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
          <SearchField
            label="Add a city to your world clock"
            hint="10 time zones"
            placeholder="Search a city…"
            items={CITY_ITEMS}
            accent="emerald"
            emptyHint="Search by city name or region."
          />
        </div>

        <p className="mt-8 text-center text-xs text-slate-400 dark:text-slate-600">
          Built with real inputs, ARIA combobox roles, and reduced-motion support.
        </p>
      </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 →