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…

Dependencies
lucide-react
Registry dependencies
utils, use-prefers-reduced-motion, focus-trap
"use client"

import * as React from "react"
import { Check, ChevronDown, RotateCcw, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"
import { trapTabKey } from "@/lib/focus-trap"

/** 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

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, plus the use-prefers-reduced-motion hook and the focus-trap lib). 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
- Shared primitive: focus-trap (installed automatically via registryDependencies)
  — trapTabKey keeps Tab and Shift+Tab inside the open panel, including from the
  panel itself, which holds focus right after opening. If rebuilding without it: a
  tabbable element is rendered with a non-zero box and computed visibility:
  visible, carries no explicit negative tabindex and sits under no inert /
  aria-hidden="true" ancestor inside the panel; Tab from the last one (or from the
  panel itself / from outside it) goes to the first and Shift+Tab from the first
  goes to the last, parking focus on the panel when nothing is tabbable.
- Shared primitive: use-prefers-reduced-motion (installed automatically via
  registryDependencies) — drops the rise, settle and exit transitions while the OS
  asks for reduced motion. If rebuilding without it: a useSyncExternalStore over
  matchMedia("(prefers-reduced-motion: reduce)") with a "change" listener and a
  server snapshot of false.
- 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. It does NOT fire on open, when the draft is reloaded from
    the applied value. The sheet never reports its own opening either
    (onOpenChange only fires for close intents), so recount against the applied
    value wherever the host sets open to true, or Apply keeps showing the count of
    a discarded draft.
  - 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, and Shift+Tab from the panel itself (where focus lands on open) wraps to
  the last control instead of leaving it; 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 (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