Filter Sheet
A bottom-edge filter panel with grouped facets, a staged draft, a live result count and an Apply bar padded clear of the home indicator.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/filter-sheet.jsonPrompt
Build a React + TypeScript + Tailwind "FilterSheet" component (lucide-react for the
four glyphs, no other dependency). It is the mobile filter panel: grouped facets
rise from the bottom edge over the list they filter, and the commit bar waits in
the thumb arc, clear of the home indicator.
Contract
- export const FilterSheet = React.forwardRef<HTMLDivElement, FilterSheetProps>,
plus the types FilterSheetOption / FilterSheetGroup / FilterSheetValue /
FilterSheetVariant / FilterSheetCountStatus / FilterSheetLabels.
- Data
- FilterSheetOption: { value, label, count?, disabled?, disabledReason? }.
- FilterSheetGroup: { id, label, description?, selection?: "single" | "multiple"
(default "multiple"), options }.
- FilterSheetValue = Record<groupId, string[]>. A group with nothing selected is
absent from the map, never present as an empty array — one shape per state.
- Props
- groups?: FilterSheetGroup[] (default []), variant?: "stacked" | "rows" |
"accordion" (default "stacked").
- open? / defaultOpen? / onOpenChange? — controlled and uncontrolled both.
- value? / defaultValue? / onValueChange? — the *applied* value, also both ways.
onValueChange fires only on Apply.
- onDraftChange?(draft) — every toggle and every Reset. This is the hook the host
recounts against.
- resultCount?: number, countStatus?: "idle" | "counting" | "ready" | "error"
(default "idle"). The component never counts anything itself.
- title? (default "Filters"), description?, maxHeight? (0–1 fraction of the
screen, default 0.85, clamped to 0.3–1), showHandle?, showReset?, autoFocus?
(all default true), labels?: Partial<FilterSheetLabels> (twelve strings),
formatCount? / formatSelected? / formatResults?.
- children — the screen being filtered. The root is that screen: it spreads the
rest of the native div props and merges className.
Behavior
- Staged editing. Opening copies the applied value into a draft; every toggle edits
the draft and calls onDraftChange. Apply commits (onValueChange + close) and is
the only thing that does. Dismissing — Close, Esc, the backdrop, a flick down —
drops the draft, so the list underneath never moves while the user is deciding.
The header sub-line reads "3 selected · not applied yet" while draft and applied
disagree.
- Value hygiene. Incoming value/defaultValue is normalized against groups: unknown
group ids and unknown option values are dropped, duplicates removed, a "single"
group capped at one value. A stale facet from a URL must not be counted in a
summary no control on screen can clear.
- Live count. countStatus drives one hint line above the button and the button
caption itself: "counting" shows a pulsing dot and keeps the generic label (never
a stale number), "error" says the count is unavailable and leaves Apply live,
"ready" with 0 says nothing matches in words instead of printing a bare 0.
- The gesture. Pointer Events only, on the handle + header drag zone
(touch-action: none there, touch-pan-y + overscroll-contain on the scrolling
facet list). A press becomes a drag after 4px; a gesture more horizontal than
vertical is handed back and never re-evaluated. setPointerCapture on the element
the gesture started on, released on that same node. Movement is written to a
transform inside one rAF per frame, offset 0 = raised, 1 = below the edge, hard
clamped at 0 (upward over-drag would open a strip of backdrop under a panel that
is anchored to the edge). Release: past 35% of the panel height, or a velocity
over 0.5px/ms (smoothed 0.3/0.7 so one jittery frame is not a fling), dismisses;
anything else springs back over 280ms. A cancelled pointer always springs back —
a draft is only discarded deliberately.
- Equal paths. Reset (header, top-left, deliberately outside the thumb arc), Close,
Esc, the backdrop and Apply cover by button and key everything the gesture does.
- Keyboard. Arrow keys walk the options of one group with wraparound, Home/End jump
to its ends; Space/Enter toggle. A "single" group is a radiogroup — roving
tabindex, one tab stop, and the selection travels with the focus (APG); a
"multiple" group keeps every checkbox in the tab order, because a checkbox only
arrow keys can reach is one most people never find. Tab is trapped inside the
panel; the backdrop covers the screen behind for the pointer and aria-modal for
assistive tech. Do NOT reach for `inert` on that screen: applying it blurs the
trigger the instant the sheet opens, so the sheet has nothing left to hand focus
back to when it closes.
- ARIA. Panel role=dialog aria-modal, labelled by the heading and described by
`description` when given. Each group is role=group or role=radiogroup labelled by
its own heading; each option is a real <button> with role=checkbox/radio and
aria-checked. An unavailable option is aria-disabled and carries its reason
inside the button, so the reason is part of the accessible name rather than a
second aria-describedby copy of it — never the native disabled attribute, which
would blur a control the user is standing on — and pressing it announces it. One
polite role=status region carries a refusal first and the settled count second,
cleared after 2.4s so an identical message can be said again. Nothing is
announced mid-gesture.
- Focus. Opening focuses the panel itself (tabIndex=-1) so the title and the
selection summary are read; closing returns focus to whatever had it, after
checking that node is still connected, so focus never lands on <body>.
- Safe area. The commit bar pads with
max(var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)), 0.75rem) and
the panel pads its sides the same way for landscape rails, so Apply clears the
home indicator. The custom property lets a device-frame preview simulate insets
on hardware that reports 0.
- Edge cases: no groups renders an empty body that still offers both exits; a group
with no options says so; a long label wraps and grows its row instead of
truncating; a one-option group is legal; an Apply guard ref is read and written
in the same handler so a double tap commits once, and is released one commit
later so a host that keeps the sheet up is not left with a dead button.
- Cleanup: the notice timer, the drag rAF and the exit timer are all cancelled on
unmount; prefers-reduced-motion is subscribed with matchMedia (not read once) and
unsubscribed, and under it the sheet appears and disappears without transitions
while every gesture still works.
- No portal, no scroll lock: the sheet renders as an absolute layer inside the
component's own box. Give the root h-dvh for a real screen; give it a phone-width
box and several fit on one page.
Rendering & styling
- Semantic tokens only: bg-background / bg-card / text-card-foreground / bg-muted /
text-muted-foreground / border / bg-accent / ring-ring. The Apply button and the
selected facets INVERT (bg-foreground text-background) instead of taking a
colour; nothing on this surface is tinted.
- Surfaces follow a 16/8 radius ladder: rounded-t-2xl panel, rounded-lg chips,
rows, and buttons. Type is small and tight — 15px/700 heading, 13px options,
11px counts and hints, all counts tabular-nums.
- Variants: "stacked" wraps chips under each group heading; "rows" is full-width
48px rows with a leading check box (round for radios) and the count on the right;
"accordion" collapses each group to one row carrying its own summary and opens
one at a time — collapsed groups are unmounted, not hidden, so there are no
invisible tab stops.
- Every hit area is at least 44px (min-h-11 / min-h-12 / size-11); nothing depends
on hover; focus-visible rings everywhere; decorative glyphs aria-hidden.
- cn() merges every className, transitions carry motion-reduce:transition-none.
Customization levers
- Density: swap min-h-12 rows for min-h-11, or the chip padding px-3 py-2, to fit
more facets per screen; maxHeight moves the panel between a half sheet (0.5) and
a full-screen one (1).
- Structure: drop the handle (showHandle) or Reset (showReset); give `description`
to replace the selection summary; the commit bar is one flex row — a secondary
"Clear" button beside Apply drops in without touching the scroll geometry.
- Content: any group can be replaced by your own control (a price range slider, a
date range) by rendering it in place of the option list — keep the group heading
and the id so the draft/apply flow still owns it.
- Motion: SETTLE_MS (280) and the cubic-bezier(0.32, 0.72, 0, 1) easing are the two
numbers to tune; CLOSE_RATIO (0.35) and FLING_VELOCITY (0.5) make dismissal
easier or harder without touching anything else.
- Colour: the whole surface is monochrome by design. If a facet family needs a
colour, spend it on the option glyph only and leave the inverted Apply button and
the selected state alone — that inversion is what marks the one committing action
on the screen.
- i18n: every string is in `labels`, and the three formatters cover counts,
summaries and the Apply caption (plural rules, grouping separators, "1000+"
ceilings for expensive counts).Concepts
- Staged draft — the sheet edits a copy and the list behind it holds still. Only Apply commits; every other exit throws the copy away. It is what makes a count meaningful and what makes an accidental swipe harmless.
- Live result count — a promise about the next screen, owned by the host and handed back through
resultCountandcountStatus. Its four states matter more than the number: pending must not show a stale count, a failed count must not disable the button, and zero must be said in words. - Facet algebra — options are OR-ed inside their group and AND-ed across groups. Written down once here because every filter UI implies it and almost none states it; the count is wrong the moment the host counts differently.
- Thumb-arc commit bar — the one action worth a fingertip sits at the bottom, padded by
env(safe-area-inset-bottom); Reset is exiled to the top-left corner precisely because it is out of reach there. - Flick-to-dismiss threshold — released past 35% of the panel height, or moving faster than 0.5px/ms, the sheet leaves; below both it springs home over 280ms. Every one of those pixels has a key that does the same thing.
- Roving tabindex vs tab order — the ARIA role decides the keyboard shape: a radiogroup gets one tab stop and moves its selection with the focus, a checkbox group keeps every option reachable by Tab. Same panel, two contracts.
Paywall Sheet
A subscription paywall that rises from the bottom edge — plan radio rows, trial framing, restore purchases and the auto-renew fine print under a pinned, safe-area action bar.
Long Press
Press-and-hold on any row or tile — a ring, bar or swell charges under the finger, drift hands the gesture back to the scroller, and the completion fires exactly once, with a tap, a held key and a 44px button all reaching the same action.