Search & FilterATM
Two dedicated controls for narrowing a list. The search box frames a native <input type="search"> with an icon and a clear (×) button that appears only while there is text - the × and Escape empty the field through the same input event typing fires. The filter turns native radios and checkboxes into category chips: choose one and the others step aside behind a reset, CSS only - after daisyUI's filter.
On this page (16)
§Search box
Type and the × appears; click it or press Escape and the field empties, keeps the focus and fires input - the same event as typing. The browser's own clear button is replaced by one that looks the same everywhere.
§Live results
One input listener filters the list - typing and clearing alike - and an <output> announces the count.
§Searching
The searching state: a spinner stands in for the icon and the field is aria-busy while the lookup runs; the page ends it with filled. Here every keystroke waits 600 ms for the (pretend) server.
§Shortcut hint, pill and muted
A trailing <kbd> (or .search-box-hint) shows while the box is empty and gives way to the × once there is text. data-shape='pill' rounds the frame; data-variant='muted' drops border and shadow for a quieter surface.
§Search box sizes
data-size sm · (default) · lg - the same heights as input, so a search box lines up with the fields and buttons beside it.
§Disabled and invalid
disabled on the input dims the whole box; aria-invalid='true' turns the frame destructive.
§Filter
Radios in a <form>: choose one and the others glide aside while the × grows in after it - nothing jumps under the pointer. The × is a native <input type='reset'>: it clears the form and the chips glide back. No script.
§Without a form
Outside a form, one radio of the group carries .filter-reset (authored last, like every reset): checking it means 'all', so it folds away and brings the others back.
§Checkboxes
Checkbox chips draw their box - empty, then ticked. Without data-multiple they behave like the radios (the first tick folds the rest away) until the reset clears them.
§Several at once
data-multiple keeps every chip in place: tick any number of categories; the reset grows in at the end as soon as one is ticked.
§Filtering content with :has()
No script: the list's container asks :has() which chip is checked and hides the cards that don't match. The reset radio shows everything again.
§Search and filter together
Both narrow the same list: the chips pick a category, the box matches text. One function re-runs on the box's input event and the form's change and reset events.
§Filter variants and sizes
data-variant secondary and outline change the chosen chip; data-shape='pill' rounds; data-size sm · (default) · lg. disabled on a chip keeps it out of reach.
§States
Named states of the search box via the shared State API, driven per instance through the bound api (the filter is CSS only):
default- empty, no ×; setting it clears the field ({ value }presets one)filled- has text, the × shows; entered automatically as you typesearching- a lookup is pending: a spinner replaces the icon and the field isaria-busy; the page ends it withfilled
The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/search-filter-{state}.png.
Machine contract - verified against search-filter.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
value | string | — | "" | The search text; set through the State API (setState('default', { value })), read from the input. |
filled | boolean | true, false | false | Has text - the × shows; entered automatically as you type. |
searching | boolean | true, false | false | A lookup is pending: spinner in place of the icon, aria-busy on the field. |
§CSS view file
/* -- Search & Filter component ---------------------------------- Two form controls for narrowing a list: .search-box - a search field with a leading icon and a clear (×) button that shows only while there is something to clear .filter - category chips on native radios / checkboxes: pick one and the others step aside behind a reset (×) - CSS only */@layer components { /* -- Search box ------------------------------------------------ */ .search-box { /* the BOX draws the field frame; the input inside is bare, so the icon and the clear button sit inside the border like one control */ box-sizing: border-box; display: flex; align-items: center; gap: 0.5rem; width: 100%; height: 2.25rem; padding-inline: 0.75rem 0.375rem; border: 1px solid var(--input); border-radius: var(--radius-md); background: var(--background); color: var(--foreground); font-size: 0.875rem; font-family: var(--font-sans); box-shadow: var(--shadow-xs); cursor: text; transition: border-color 150ms, box-shadow 150ms; &:focus-within { border-color: var(--ring); box-shadow: 0 0 0 2px oklch(from var(--ring) l c h / 0.2); } /* the leading icon (an <svg> or a lucide <i> before the input) */ & > :is(svg, i, .search-box-icon) { flex: none; width: 1em; height: 1em; color: var(--muted-foreground); } & > input { flex: 1; min-width: 0; height: 100%; margin: 0; padding: 0; border: 0; outline: none; background: transparent; color: inherit; font: inherit; appearance: none; &::placeholder { color: var(--muted-foreground); } /* the box brings its own clear button - drop the UA's */ &::-webkit-search-cancel-button, &::-webkit-search-decoration { appearance: none; display: none; } &:disabled { cursor: not-allowed; } } &:has(> input:disabled) { background: var(--muted); color: var(--muted-foreground); box-shadow: none; cursor: not-allowed; } &:has(> input[aria-invalid="true"]) { border-color: var(--destructive); &:focus-within { box-shadow: 0 0 0 2px oklch(from var(--destructive) l c h / 0.2); } } /* -- Clear button: a CSS-drawn ×, only while there is text -------- */ & > .search-box-clear { position: relative; flex: none; display: none; width: 1.5rem; height: 1.5rem; margin: 0; padding: 0; border: 0; border-radius: 999px; background: transparent; color: var(--muted-foreground); cursor: pointer; transition: background-color 150ms, color 150ms; &::before, &::after { content: ""; position: absolute; inset: 50% auto auto 50%; width: 0.75rem; height: 1.5px; border-radius: 1px; background: currentColor; translate: -50% -50%; rotate: 45deg; } &::after { rotate: -45deg; } &:hover { background: var(--accent); color: var(--accent-foreground); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; } } /* the runtime names the state: filled / searching = there is a value */ &:is([data-state-name="filled"], [data-state-name="searching"]) { & > .search-box-clear { display: block; } /* a trailing hint (a <kbd> shortcut, a scope label) steps aside */ & > :is(kbd, .search-box-hint) { display: none; } } & > :is(kbd, .search-box-hint) { flex: none; margin-inline-end: 0.125rem; } & > .search-box-hint { color: var(--muted-foreground); font-size: 0.75em; } /* -- Searching: a spinner stands in for the leading icon ---------- */ &::before { content: ""; display: none; flex: none; box-sizing: border-box; width: 1em; height: 1em; border: 2px solid color-mix(in oklch, var(--muted-foreground) 30%, transparent); border-top-color: var(--muted-foreground); border-radius: 50%; animation: search-box-spin 700ms linear infinite; } &[data-state-name="searching"] { &::before { display: block; } & > :is(svg, i, .search-box-icon) { display: none; } } /* -- Variants / shape / sizes ------------------------------------- */ &[data-variant="muted"] { background: var(--muted); border-color: transparent; box-shadow: none; } &[data-shape="pill"] { border-radius: 999px; padding-inline-start: 0.875rem; } &[data-size="sm"] { height: 2rem; padding-inline: 0.625rem 0.25rem; font-size: 0.8125rem; & > .search-box-clear { width: 1.25rem; height: 1.25rem; } } &[data-size="lg"] { height: 2.75rem; padding-inline: 1rem 0.5rem; font-size: 1rem; & > .search-box-clear { width: 1.75rem; height: 1.75rem; } } } /* -- Filter ------------------------------------------------------ */ .filter { display: flex; flex-wrap: wrap; align-items: center; /* chips space themselves (margin, not gap): a collapsed chip must take no room at all, and a gap would stay behind it */ row-gap: 0.375rem; /* chips collapse to and grow from width 0 - animate to/from auto */ interpolate-size: allow-keywords; /* a <form> or <fieldset> host carries no box of its own */ margin: 0; padding: 0; border: 0; min-width: 0; /* every chip is a native radio / checkbox (or the reset button) - appearance: none, the label drawn from aria-label */ & > input { appearance: none; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; flex: none; min-width: 0; width: auto; height: 2rem; margin: 0 0.375rem 0 0; padding: 0 0.875rem; border: 1px solid var(--input); border-radius: var(--radius-md); overflow: hidden; background: var(--background); color: var(--foreground); font: 500 0.875rem/1 var(--font-sans); white-space: nowrap; cursor: pointer; box-shadow: var(--shadow-xs); /* choosing a chip never jumps: the others shrink to nothing and the reset grows, all in one glide, and the chosen chip slides along */ transition: background-color 150ms, color 150ms, border-color 150ms, width 250ms ease, padding 250ms ease, margin 250ms ease, border-width 250ms ease, opacity 200ms, scale 250ms ease, visibility 250ms allow-discrete; &:is([type="radio"], [type="checkbox"]):not(.filter-reset)::after { content: attr(aria-label); } /* a checkbox chip shows its box: several may be ticked */ &[type="checkbox"]::before { content: ""; flex: none; box-sizing: border-box; width: 0.875em; height: 0.875em; margin-inline-end: 0.5em; border: 1.5px solid currentColor; border-radius: 0.25em; opacity: 0.7; } &[type="checkbox"]:checked::before { opacity: 1; background: currentColor; /* the tick is cut out of the filled box, so it shows the chip colour */ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-6.5' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 85% no-repeat, linear-gradient(#000 0 0); mask-composite: exclude; } &:hover { background: var(--accent); color: var(--accent-foreground); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } &:checked { background: var(--primary); border-color: var(--primary); color: var(--primary-foreground); } &:disabled { opacity: 0.5; cursor: not-allowed; &:hover { background: var(--background); color: var(--foreground); } } } /* the reset: <input type="reset" value="×"> in a form, or a radio of the group with .filter-reset outside one - a square ×, authored LAST so it appears after the chosen chip and nothing moves under the pointer */ & > :is(input[type="reset"], .filter-reset) { width: 2rem; padding: 0; font-size: 1.25rem; font-weight: 400; color: var(--muted-foreground); } & > .filter-reset::after { content: "×"; } /* -- Variants / shape / sizes ------------------------------------- */ &[data-variant="secondary"] > input:checked { background: var(--secondary); border-color: var(--secondary); color: var(--secondary-foreground); } &[data-variant="outline"] > input:checked { background: var(--background); border-color: var(--foreground); color: var(--foreground); box-shadow: inset 0 0 0 1px var(--foreground); } &[data-shape="pill"] > input { border-radius: 999px; } &[data-size="sm"] > input { height: 1.75rem; padding-inline: 0.625rem; font-size: 0.75rem; &:is([type="reset"], .filter-reset) { width: 1.75rem; padding: 0; font-size: 1rem; } } &[data-size="lg"] > input { height: 2.5rem; padding-inline: 1.125rem; font-size: 1rem; &:is([type="reset"], .filter-reset) { width: 2.5rem; padding: 0; font-size: 1.5rem; } } /* -- Collapsed chips (after the sizes: these win) -------------------- nothing chosen yet (or the reset radio is the choice): no reset; one chosen: the others step aside until the reset brings them back. Collapsed = no width, no box, hidden from pointer, focus and the radio group's arrow keys (visibility flips at the END of the glide when hiding, at the start when showing). */ &:not(:has(> input:checked:not(.filter-reset))) > :is(input[type="reset"], .filter-reset), &:not([data-multiple]):has(> input:checked:not(.filter-reset)) > input:not(:checked, .filter-reset, [type="reset"]) { width: 0; padding-inline: 0; margin-inline-end: 0; border-inline-width: 0; opacity: 0; scale: 0.8; box-shadow: none; visibility: hidden; pointer-events: none; } } @keyframes search-box-spin { to { rotate: 360deg; } } /* -- Accessibility -------------------------------------------- */ @media (prefers-reduced-motion: reduce) { .search-box, .search-box > .search-box-clear, .filter > input { transition: none; } /* the spinner still says "working" - slowly */ .search-box::before { animation-duration: 2s; } } @media (prefers-contrast: more) { .search-box { border-color: var(--foreground); } .search-box > .search-box-clear { color: var(--foreground); } .filter > input { border-color: var(--foreground); } } @media (forced-colors: active) { .search-box { border: 1px solid CanvasText; } .search-box:focus-within { outline: 2px solid Highlight; } .search-box > .search-box-clear::before, .search-box > .search-box-clear::after { background: ButtonText; } .filter > input { border: 1px solid ButtonText; } .filter > input:checked { background: Highlight; color: HighlightText; forced-color-adjust: none; } }}§JS view file
// -- Search & Filter -------------------------------------------// The search box: a native <input type="search"> inside a frame with a// leading icon and a clear (×) button. The runtime only does what CSS can't:// knowing whether there is text (the × shows then), clearing it - by the// button or Escape - and telling the page, through the same `input` event// typing fires, so one listener handles both. The filter chips are CSS only.// Plus the named-state API (AGENTS.md "State API").// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals } from '../../shared/state-api.js';const df$ = defussGlobals();const searchFilterStates = ['default', 'filled', 'searching'];/** Sets the field's value and lets listeners know, exactly as typing would. */function setValue(box, value) { const field = box._field; if (field.value === value) return; field.value = value; field.dispatchEvent(new Event('input', { bubbles: true }));}/** * UI side of setState. Every state accepts `{ value }`. 'default' is the * empty box (it clears the field unless given a value); 'filled' has text; * 'searching' has text and a pending lookup - a spinner in place of the * icon, aria-busy on the field. */function triggerStateChange(box, stateName, config) { const field = box._field; const value = typeof config.value === 'string' ? config.value : undefined; if (stateName === 'searching') field.setAttribute('aria-busy', 'true'); else field.removeAttribute('aria-busy'); switch (stateName) { case 'default': setValue(box, value ?? ''); break; case 'filled': case 'searching': if (value !== undefined) setValue(box, value); break; }}/** Registry-level API; pass the .search-box element explicitly. Unknown names throw. */export const searchFilterApi = { setState(box, stateName, config = {}) { if (!searchFilterStates.includes(stateName)) { throw new Error( `search-filter: unknown state "${stateName}" (supported: ${searchFilterStates.join(', ')})`, ); } // named before the DOM changes: the input event a new value fires reads // it, and must not overwrite 'searching' with 'filled' box.dataset.stateName = stateName; box._stateConfig = config; triggerStateChange(box, stateName, config); }, getState(box) { return { name: box.dataset.stateName || 'default', config: box._stateConfig ?? {} }; },};df$.searchFilterApi = searchFilterApi;df$.searchFilterStates = searchFilterStates;/** Empties the field, keeps the caret in it, and says so. */function clear(box) { searchFilterApi.setState(box, 'default', {}); box._field.focus(); box.dispatchEvent(new CustomEvent('search-clear', { bubbles: true }));}function init() { document.querySelectorAll('.search-box:not([data-init])').forEach((box) => { box.dataset.init = ''; const field = box.querySelector(':scope > input'); if (!field) return; // the input is authored, not generated — nothing to drive box._field = field; if (!field.getAttribute('enterkeyhint')) field.setAttribute('enterkeyhint', 'search'); // typing (or a setValue) moves between empty and filled; a pending // 'searching' survives further typing - the page ends it field.addEventListener('input', () => { const name = box.dataset.stateName; if (field.value === '') { if (name !== 'default') searchFilterApi.setState(box, 'default', {}); } else if (name !== 'searching' && name !== 'filled') { searchFilterApi.setState(box, 'filled', {}); } }); // Escape clears a filled box first; the default is prevented so an // enclosing dialog or popover closes only on the next Escape field.addEventListener('keydown', (e) => { if (e.key === 'Escape' && field.value !== '') { e.preventDefault(); e.stopPropagation(); clear(box); } }); box.querySelector(':scope > .search-box-clear')?.addEventListener('click', () => clear(box)); // a click on the frame (icon, padding) lands in the field box.addEventListener('mousedown', (e) => { if (e.target !== field && !e.target.closest('button, a')) { e.preventDefault(); field.focus(); } }); box.api = { setState: (stateName, config) => searchFilterApi.setState(box, stateName, config), getState: () => searchFilterApi.getState(box), }; searchFilterApi.setState(box, field.value === '' ? 'default' : 'filled', {}); });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub