Search Winnow

Search

Search input that winnows its list like grain from chaff: non-matching rows tumble aside with a slight rotation and fade, then their slot collapses so survivors settle together.

Install
npx shadcn add https://design.helpmarq.com/r/search-winnow.json
Source
registry/core/search-winnow/component.tsx
"use client";

import { useId, useMemo, useRef, useState } from "react";

// Search input that winnows its list like grain from chaff: as the query
// narrows, non-matching rows lose their weight — drifting aside with a slight
// tumble and fading — then their slot collapses so the survivors settle
// together. Matches keep full ink with the matched span emphasized. Two-phase
// exit (drift, then collapse) is pure CSS: an outer grid-rows 1fr->0fr
// collapse delayed behind the inner drift. Combobox keyboard pattern:
// ArrowUp/Down roves an active option, Enter selects, Escape clears.

export interface ChaffWinnowItem {
  id: string;
  label: string;
  hint?: string;
}

export interface ChaffWinnowProps {
  items: ChaffWinnowItem[];
  placeholder?: string;
  /** accessible name for the search input */
  label?: string;
  onSelect?: (item: ChaffWinnowItem) => void;
  className?: string;
}

function Highlight({ text, query }: { text: string; query: string }) {
  if (!query) return <>{text}</>;
  const i = text.toLowerCase().indexOf(query.toLowerCase());
  if (i === -1) return <>{text}</>;
  return (
    <>
      {text.slice(0, i)}
      <mark className="rounded-[3px] bg-border/80 px-0.5 font-medium text-foreground">
        {text.slice(i, i + query.length)}
      </mark>
      {text.slice(i + query.length)}
    </>
  );
}

export function ChaffWinnow({
  items,
  placeholder = "Search…",
  label = "Search",
  onSelect,
  className = "",
}: ChaffWinnowProps) {
  const [query, setQuery] = useState("");
  const [active, setActive] = useState(-1);
  const listId = useId();
  const reduced = useRef(
    typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches,
  ).current;

  const q = query.trim();
  const matched = useMemo(() => {
    if (!q) return new Set(items.map((it) => it.id));
    const lq = q.toLowerCase();
    return new Set(
      items
        .filter(
          (it) =>
            it.label.toLowerCase().includes(lq) || it.hint?.toLowerCase().includes(lq),
        )
        .map((it) => it.id),
    );
  }, [items, q]);

  const visible = items.filter((it) => matched.has(it.id));
  const clampedActive = Math.min(active, visible.length - 1);

  const onKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "ArrowDown") {
      e.preventDefault();
      setActive((a) => Math.min((a < 0 ? -1 : Math.min(a, visible.length - 1)) + 1, visible.length - 1));
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      setActive((a) => Math.max(Math.min(a, visible.length - 1) - 1, 0));
    } else if (e.key === "Enter" && clampedActive >= 0 && visible[clampedActive]) {
      e.preventDefault();
      onSelect?.(visible[clampedActive]);
    } else if (e.key === "Escape") {
      setQuery("");
      setActive(-1);
    }
  };

  return (
    <div className={["w-full rounded-md border border-border bg-surface", className].join(" ")}>
      <div className="flex items-center gap-2.5 border-b border-border px-4 py-3">
        <svg aria-hidden width={15} height={15} viewBox="0 0 15 15" className="shrink-0 text-muted">
          <circle cx={6.5} cy={6.5} r={4.5} fill="none" stroke="currentColor" strokeWidth={1.5} />
          <path d="M 10 10 L 13.5 13.5" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" />
        </svg>
        <input
          type="text"
          role="combobox"
          aria-label={label}
          aria-expanded={visible.length > 0}
          aria-controls={listId}
          aria-activedescendant={
            clampedActive >= 0 && visible[clampedActive]
              ? `${listId}-${visible[clampedActive].id}`
              : undefined
          }
          aria-autocomplete="list"
          value={query}
          placeholder={placeholder}
          onChange={(e) => {
            setQuery(e.target.value);
            setActive(-1);
          }}
          onKeyDown={onKeyDown}
          className="w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted"
        />
        <span aria-live="polite" className="shrink-0 font-mono text-[11px] tabular-nums text-muted">
          {visible.length}/{items.length}
        </span>
      </div>
      <ul id={listId} role="listbox" aria-label={label} className="py-1">
        {items.map((it) => {
          const out = !matched.has(it.id);
          const vi = visible.findIndex((v) => v.id === it.id);
          const isActive = vi >= 0 && vi === clampedActive;
          return (
            // outer: slot collapse, delayed behind the inner drift so the
            // row visibly blows aside before its space closes
            <li
              key={it.id}
              role={out ? "presentation" : "option"}
              id={out ? undefined : `${listId}-${it.id}`}
              aria-selected={out ? undefined : isActive}
              className="grid"
              style={{
                gridTemplateRows: out ? "0fr" : "1fr",
                transition: reduced
                  ? "none"
                  : `grid-template-rows 280ms cubic-bezier(0.22, 1, 0.36, 1) ${out ? "140ms" : "0ms"}`,
              }}
            >
              <div className="min-h-0 overflow-hidden">
                <div
                  onClick={out ? undefined : () => onSelect?.(it)}
                  className={[
                    "mx-1 flex items-baseline gap-3 rounded-sm px-3 py-2",
                    out ? "" : "cursor-pointer",
                    isActive ? "bg-border/60" : out ? "" : "hover:bg-border/40",
                    "transition-[background-color] duration-150",
                  ].join(" ")}
                  style={{
                    opacity: out ? 0 : 1,
                    transform: out
                      ? "translateX(14px) rotate(1.5deg)"
                      : "translateX(0) rotate(0deg)",
                    transition: reduced
                      ? "opacity 0ms"
                      : `opacity 220ms ease, transform 260ms cubic-bezier(0.55, 0, 0.55, 0.2), background-color 150ms ease`,
                  }}
                >
                  <span className="min-w-0 flex-1 truncate text-sm text-foreground">
                    <Highlight text={it.label} query={q} />
                  </span>
                  {it.hint ? (
                    <span className="shrink-0 font-mono text-[11px] text-muted">
                      <Highlight text={it.hint} query={q} />
                    </span>
                  ) : null}
                </div>
              </div>
            </li>
          );
        })}
        <li
          role="presentation"
          className="grid"
          style={{
            gridTemplateRows: visible.length === 0 ? "1fr" : "0fr",
            transition: reduced
              ? "none"
              : "grid-template-rows 280ms cubic-bezier(0.22, 1, 0.36, 1) 140ms",
          }}
        >
          <div className="min-h-0 overflow-hidden">
            <p className="px-4 py-2 font-mono text-[11px] text-muted">
              nothing survives “{q}”
            </p>
          </div>
        </li>
      </ul>
    </div>
  );
}
Use when

Search input that winnows its list like grain from chaff: non-matching rows tumble aside with a slight rotation and fade, then their slot collapses so survivors settle together. Clearing the query lets the chaff drift back in. Full combobox keyboard pattern with a live match counter.

Build spec

Build a filtering search list where rejection is a physical winnowing motion, in two phases driven purely by CSS. STRUCTURE: bordered bg-surface card; header row with an inline SVG magnifier (stroke=currentColor on text-muted), a borderless text input, and an aria-live 'visible/total' mono counter; below, a <ul role=listbox>. Each item renders inside a two-layer exit shell: an outer <li class=grid> whose gridTemplateRows transitions 1fr -> 0fr (280ms ease-out, DELAYED 140ms) and an inner div (min-h-0 overflow-hidden wrapper) that drifts translateX(14px) rotate(1.5deg) and fades over ~240ms with an ease-in curve. The delay ordering is the mechanism: the row visibly tumbles aside first, THEN its slot closes and the survivors settle up; re-matching reverses both with zero delay so returns feel immediate. FILTERING: case-insensitive substring over label and hint; the matched span in both is wrapped in a <mark> tinted bg-border/80 with font-medium (token-only highlight, no accent — accent is reserved for interaction states). A trailing 'nothing survives \"query\"' row collapses in via the same grid mechanism when zero match. KEYBOARD: the input is role=combobox with aria-expanded, aria-controls, aria-autocomplete=list and aria-activedescendant pointing at the active option; ArrowDown/ArrowUp rove the active row (clamped to the visible set), Enter fires onSelect with the active item, Escape clears the query; filtered-out rows drop their option role and id so the accessibility tree only contains real matches. Active row tints bg-border/60, hover bg-border/40. INK: tokens only — surface, border, muted, foreground; both themes render. Reduced motion: all transitions none, rows appear/disappear instantly. No canvas, no timers, no observers, no JS animation — state is the query string and active index, everything kinetic is CSS transitions.

Tags
searchfiltercomboboxinputformmicro-interaction