Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, ChevronDown, RotateCcw, X } from "lucide-react"
import { cn } from "@/lib/utils"

/** Rise / settle / exit duration (ms). Also how long the exit is held before the panel unmounts. */
const SETTLE_MS = 280
/** Movement (px) on the drag zone before a press becomes a drag. Below it, a press is still a press. */
const DRAG_START_PX = 4
/** Released past this fraction of the panel's own height it dismisses; below it, it springs back. */
const CLOSE_RATIO = 0.35
/** Fling threshold (px/ms): a fast flick down dismisses from four pixels in. */
const FLING_VELOCITY = 0.5

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/filter-sheet.json

Prompt

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 resultCount and countStatus. 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.

On This Page