Sieve

No dependencies
Code format
React + Tailwind
import { useId, useState, type CSSProperties } from 'react';

const ITEMS = ['Apple', 'Apricot', 'Banana', 'Blueberry', 'Cherry', 'Date', 'Fig', 'Grape'];
const chip = (s: string) => 16 + s.length * 6.6;
// Lay chips out in rows, centred on cx, starting at row y; `scale` shrinks them for the bowl.
function heap(list: string[], cx: number, top: number, width: number, rowH: number, scale = 1) {
  const rows: string[][] = [[]];
  let w = 0;
  for (const s of list) {
    const cw = chip(s) * scale + 4;
    if (w + cw > width && rows.at(-1)!.length) (rows.push([]), (w = 0));
    rows.at(-1)!.push(s);
    w += cw;
  }
  const at: Record<string, { x: number; y: number }> = {};
  rows.forEach((r, ri) => {
    const total = r.reduce((s, x) => s + chip(x) * scale + 4, -4);
    let x = cx - total / 2;
    for (const s of r) {
      at[s] = { x: x + (chip(s) * scale) / 2, y: top + ri * rowH };
      x += chip(s) * scale + 4;
    }
  });
  return at;
}

const css = `
  .field-sieve { --mesh: light-dark(#6d7179, #7d8087); --rim: light-dark(#50535b, #a7abb2); display: grid; gap: 6px; width: 262px; }
  .field-sieve__field { display: flex; align-items: center; gap: 8px; height: 36px; padding: 0 12px; border-radius: 18px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
  .field-sieve__field:has(.field-sieve__input:focus-visible) { box-shadow: inset 0 0 0 1px light-dark(#6d7179, #7d8087), 0 0 0 2px light-dark(#5f626a, #a7abb2); }
  .field-sieve__field svg { width: 15px; height: 15px; flex: none; fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 2; stroke-linecap: round; }
  .field-sieve__input { flex: 1; min-width: 0; height: 100%; margin: 0; padding: 0; border: 0; background: transparent; color: light-dark(#373a40, #f1f2f3); font: 500 14px system-ui, sans-serif; outline: none; }
  .field-sieve__input::placeholder { color: light-dark(#6d7179, #7d8087); }
  .field-sieve__input::-webkit-search-cancel-button { appearance: none; }
  .field-sieve__count { color: light-dark(#5f626a, #a7abb2); font: 600 11px ui-monospace, 'SF Mono', Menlo, Consolas, monospace; white-space: nowrap; }
  .field-sieve__scene { position: relative; height: 158px; }
  .field-sieve__art { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
  .field-sieve__mesh { fill: url(#field-sieve-mesh); stroke: none; }
  .field-sieve__mesh-line { stroke: var(--mesh); stroke-width: 0.7; }
  .field-sieve__rim { fill: none; stroke: var(--rim); stroke-width: 3; stroke-linecap: round; }
  .field-sieve__bowl { fill: color-mix(in oklab, light-dark(#dbdde1, #2e3035) 70%, transparent); stroke: light-dark(#cbced3, #393c41); stroke-width: 1.5; }
  .field-sieve__sieve { transform-box: view-box; transform-origin: 120px 60px; }
  .field-sieve__scene[data-sift='0'] .field-sieve__sieve, .field-sieve__scene[data-sift='0'] .field-sieve__item--in { animation: field-sieve-sift-a 0.5s ease-out; }
  .field-sieve__scene[data-sift='1'] .field-sieve__sieve, .field-sieve__scene[data-sift='1'] .field-sieve__item--in { animation: field-sieve-sift-b 0.5s ease-out; }
  @keyframes field-sieve-sift-a { 20% { translate: -5px 0; } 45% { translate: 4px 0; } 70% { translate: -2px 0; } }
  @keyframes field-sieve-sift-b { 20% { translate: -5px 0; } 45% { translate: 4px 0; } 70% { translate: -2px 0; } }
  /* Each item knows its spot in the sieve or the bowl; falling accelerates, rising springs. */
  .field-sieve__item { position: absolute; left: 0; top: 0; height: 20px; margin: -10px 0 0; padding: 0 8px; border-radius: 10px; background: light-dark(#42454c, #e5e6e9); color: light-dark(#fbfcfd, #121417); font: 600 11px/20px system-ui, sans-serif; white-space: nowrap; transform: translate(calc(var(--x) - 50%), var(--y)) scale(var(--s)); box-shadow: 0 2px 3px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3)); transition: transform 0.6s cubic-bezier(0.2, 0.9, 0.3, 1.2), opacity 0.4s; }
  .field-sieve__item--out { opacity: 0.55; transition: transform 0.55s cubic-bezier(0.55, 0, 0.85, 0.5), opacity 0.4s 0.3s; }
  .field-sieve__dust { position: absolute; top: 96px; width: 2px; height: 2px; border-radius: 50%; background: light-dark(#50535b, #c6c8cd); opacity: 0; animation: field-sieve-dust 0.7s ease-in; }
  @keyframes field-sieve-dust { 0% { opacity: 0.8; } 100% { opacity: 0; transform: translateY(26px); } }
  @media (prefers-reduced-motion: reduce) {
    .field-sieve * { animation: none !important; transition: none !important; }
  }
`;

export function Preview() {
  const id = useId();
  const [q, setQ] = useState('ap');
  const [sift, setSift] = useState(0);
  const query = q.trim().toLowerCase();
  const keep = ITEMS.filter((s) => s.toLowerCase().includes(query));
  const drop = ITEMS.filter((s) => !keep.includes(s));
  const inSieve = heap(keep, 120, 50, 150, 23);
  const inBowl = heap(drop, 120, 132, 120, 12, 0.6);
  return (
    <>
      <style href="kit-inputs-sieve" precedence="kit">
        {css}
      </style>
      <div className="field-sieve">
        <label className="field-sieve__field">
          <svg viewBox="0 0 24 24" aria-hidden="true">
            <circle cx={10.5} cy={10.5} r={6.5} />
            <path d="M15.5 15.5L20 20" />
          </svg>
          <input
            id={id}
            className="field-sieve__input"
            type="search"
            aria-label="Filter fruit"
            placeholder="Filter fruit"
            value={q}
            onChange={(e) => {
              setQ(e.target.value);
              setSift((s) => s + 1);
            }}
          />
          <span className="field-sieve__count" aria-live="polite">
            {keep.length} of {ITEMS.length}
          </span>
        </label>
        <div className="field-sieve__scene" data-sift={sift ? sift % 2 : undefined} aria-hidden="true">
          <svg className="field-sieve__art" viewBox="0 0 262 158">
            <defs>
              <pattern id="field-sieve-mesh" width={5} height={5} patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
                <path className="field-sieve__mesh-line" d="M0 0V5M0 0H5" />
              </pattern>
            </defs>
            <path className="field-sieve__bowl" d="M58 128Q60 154 120 154Q180 154 182 128Z" />
            <g className="field-sieve__sieve">
              <path className="field-sieve__mesh" d="M30 40Q34 98 120 98Q206 98 210 40Z" />
              <path className="field-sieve__rim" d="M30 40Q34 98 120 98Q206 98 210 40" style={{ strokeWidth: 1.4 }} />
              <path className="field-sieve__rim" d="M24 40H216M216 40L254 26" />
            </g>
          </svg>
          {ITEMS.map((s) => {
            const p = inSieve[s] ?? inBowl[s];
            const kept = s in inSieve;
            return (
              <span key={s} className={`field-sieve__item field-sieve__item--${kept ? 'in' : 'out'}`} style={{ '--x': `${p.x}px`, '--y': `${p.y}px`, '--s': kept ? 1 : 0.6 } as CSSProperties}>
                {s}
              </span>
            );
          })}
          {sift > 0 && (
            <span key={sift}>
              {[70, 96, 118, 140, 166].map((x, k) => (
                <i key={x} className="field-sieve__dust" style={{ left: x, animationDelay: `${k * 60}ms` }} />
              ))}
            </span>
          )}
        </div>
      </div>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/input-sieve
Source on GitHub

About this component

A filter field over a sieve: as you type it sifts, the items that do not match fall through the mesh into the bowl, and the rest gather in the middle.

Dependencies
None
Difficulty
Intermediate
Triggers
state change, focus, keyframe, spring
Techniques
css transition, keyframes, svg, css variables
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A native search input named "Filter fruit" with a placeholder; focus outlines the field. The match count is read out from an aria-live span, and the sieve scene is aria-hidden. Under prefers-reduced-motion items move at once.

More inputs

See all

Message in a bottle

Try SAVE20

Coupon
Label maker
Letter board