Sieve
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldSieve(el) on the mounted element instead.
<div class="field-sieve">
<label class="field-sieve__field">
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="10.5" cy="10.5" r="6.5"></circle>
<path d="M15.5 15.5L20 20"></path>
</svg>
<input id="field-sieve" class="field-sieve__input" type="search" aria-label="Filter fruit" placeholder="Filter fruit" value="ap"/>
<span class="field-sieve__count" aria-live="polite">3 of 8</span>
</label>
<div class="field-sieve__scene" aria-hidden="true">
<svg class="field-sieve__art" viewBox="0 0 262 158">
<defs>
<pattern id="field-sieve-mesh" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<path class="field-sieve__mesh-line" d="M0 0V5M0 0H5"></path>
</pattern>
</defs>
<path class="field-sieve__bowl" d="M58 128Q60 154 120 154Q180 154 182 128Z"></path>
<g class="field-sieve__sieve">
<path class="field-sieve__mesh" d="M30 40Q34 98 120 98Q206 98 210 40Z"></path>
<path class="field-sieve__rim" d="M30 40Q34 98 120 98Q206 98 210 40" style="stroke-width:1.4"></path>
<path class="field-sieve__rim" d="M24 40H216M216 40L254 26"></path>
</g>
</svg>
<span class="field-sieve__item field-sieve__item--in" style="--x:86.9px;--y:50px;--s:1">Apple</span>
<span class="field-sieve__item field-sieve__item--in" style="--x:146.5px;--y:50px;--s:1">Apricot</span>
<span class="field-sieve__item field-sieve__item--out" style="--x:95.38px;--y:132px;--s:0.6">Banana</span>
<span class="field-sieve__item field-sieve__item--out" style="--x:138.68px;--y:132px;--s:0.6">Blueberry</span>
<span class="field-sieve__item field-sieve__item--out" style="--x:92.54px;--y:144px;--s:0.6">Cherry</span>
<span class="field-sieve__item field-sieve__item--out" style="--x:125.94px;--y:144px;--s:0.6">Date</span>
<span class="field-sieve__item field-sieve__item--out" style="--x:153.4px;--y:144px;--s:0.6">Fig</span>
<span class="field-sieve__item field-sieve__item--in" style="--x:120px;--y:73px;--s:1">Grape</span>
</div>
</div>.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; }
}function initFieldSieve(root) {
var scene = root.querySelector('.field-sieve__scene');
var input = root.querySelector('.field-sieve__input');
var count = root.querySelector('.field-sieve__count');
var items = [].slice.call(root.querySelectorAll('.field-sieve__item'));
var names = items.map(function (el) {
return el.textContent;
});
var sift = 0;
var dust = null;
function chip(s) {
return 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, cx, top, width, rowH, scale) {
var rows = [[]];
var w = 0;
list.forEach(function (s) {
var cw = chip(s) * scale + 4;
if (w + cw > width && rows[rows.length - 1].length) {
rows.push([]);
w = 0;
}
rows[rows.length - 1].push(s);
w += cw;
});
var at = {};
rows.forEach(function (r, ri) {
var total = r.reduce(function (s, x) {
return s + chip(x) * scale + 4;
}, -4);
var x = cx - total / 2;
r.forEach(function (s) {
at[s] = { x: x + (chip(s) * scale) / 2, y: top + ri * rowH };
x += chip(s) * scale + 4;
});
});
return at;
}
function onInput() {
var query = input.value.trim().toLowerCase();
var keep = names.filter(function (s) {
return s.toLowerCase().indexOf(query) !== -1;
});
var drop = names.filter(function (s) {
return keep.indexOf(s) === -1;
});
var inSieve = heap(keep, 120, 50, 150, 23, 1);
var inBowl = heap(drop, 120, 132, 120, 12, 0.6);
// Every item moves to its new place: matches rise into the sieve, the rest fall into the bowl.
items.forEach(function (el, i) {
var kept = names[i] in inSieve;
var p = kept ? inSieve[names[i]] : inBowl[names[i]];
el.classList.toggle('field-sieve__item--in', kept);
el.classList.toggle('field-sieve__item--out', !kept);
el.style.setProperty('--x', p.x + 'px');
el.style.setProperty('--y', p.y + 'px');
el.style.setProperty('--s', kept ? 1 : 0.6);
});
count.textContent = keep.length + ' of ' + names.length;
// Shake the sieve (two names, so it replays) and let some dust fall through.
scene.setAttribute('data-sift', String(++sift % 2));
if (dust) dust.remove();
dust = document.createElement('span');
dust.innerHTML = [70, 96, 118, 140, 166].map(function (x, k) {
return '<i class="field-sieve__dust" style="left:' + x + 'px;animation-delay:' + k * 60 + 'ms"></i>';
}).join('');
scene.appendChild(dust);
}
input.addEventListener('input', onInput);
return function () {
input.removeEventListener('input', onInput);
};
}
document.querySelectorAll('.field-sieve').forEach(initFieldSieve);Goal: Create a "Sieve" input.
Visual behavior: 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.
Technique:
- A real search input filters a fixed list; every item is laid out again, the matches in rows inside the sieve and the rest shrunk into a pile in the bowl.
- Each item moves to its new place with a transform transition driven by --x, --y and --s: a match rises on a springy curve, a miss falls on an accelerating one and dims.
- Every keystroke shakes the sieve with the matches in it (two alternating keyframes on a data attribute so it replays) and drops a little dust through the mesh.
- The sieve is an SVG bowl filled with a rotated line pattern for the mesh, with its rim and handle.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Sieve" input.
Visual behavior: 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.
Technique:
- A real search input filters a fixed list; every item is laid out again, the matches in rows inside the sieve and the rest shrunk into a pile in the bowl.
- Each item moves to its new place with a transform transition driven by --x, --y and --s: a match rises on a springy curve, a miss falls on an accelerating one and dims.
- Every keystroke shakes the sieve with the matches in it (two alternating keyframes on a data attribute so it replays) and drops a little dust through the mesh.
- The sieve is an SVG bowl filled with a rotated line pattern for the mesh, with its rim and handle.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.