Browse/block
Filter sheet
The filter panel in a slide-over for small screens. Changes are staged and applied once — on Show or on close — with a live result count on the button and focus returned to the trigger. Accepts the same FilterPanel parts as children, so a custom layout stages too.
Step 1Register the namespace — once per project
$ npx shadcn@latest registry add @ecomcn=https://ecomcn.vercel.app/r/{name}.jsonStep 2Add the block
$ npx shadcn@latest add @ecomcn/filter-sheetStep 1 is not optional for this one — it pulls another ecomcn block, and the CLI has no way to resolve @ecomcn/… until the namespace is registered.
Preview — live
100%
Usage
components/mobile-filters.tsx"use client"
import { FilterSheet } from "@/components/ecomcn/filter-sheet"
import { useFilterParams } from "@/hooks/use-filter-params"
import { facets } from "@/lib/facets"
import type { FilterState } from "@/lib/filter-params"
export function MobileFilters({ countFor }: { countFor: (f: FilterState) => number }) {
const [filters, setFilters] = useFilterParams(facets)
return (
<FilterSheet
facets={facets}
value={filters}
// Called once, when the sheet applies — not on every tap inside it.
onValueChange={setFilters}
getResultCount={countFor}
/>
)
}Same custom layout, staged// Children are FilterPanel parts. They render inside a panel holding the
// *staged* value, so nothing reaches the URL until the sheet applies.
<FilterSheet facets={facets} value={filters} onValueChange={setFilters}>
<FilterPanelChips />
<FilterPanelFacet id="price" />
<FilterPanelFacet id="color" />
</FilterSheet>Counting needs a request? Stage, fetch, pass it backconst [count, setCount] = React.useState<number>()
<FilterSheet
facets={facets}
value={filters}
onValueChange={setFilters}
onStagedChange={async (staged) => setCount(await fetchCount(staged))}
resultCount={count}
/>Props
Read straight out of the source at build time, so this cannot drift from the file you install.
FilterSheetProps/** The applied filters — usually straight from `useFilterParams`. */
value: FilterState
/** Called once with the staged filters when the sheet applies — never per tap. */
onValueChange: (next: FilterState) => void
/** Result count for the *staged* filters, shown on the apply button. */
getResultCount?: (staged: FilterState) => number | undefined
/**
* Fires on every staged change. When counting needs a request, fetch here
* and pass the answer back as `resultCount`.
*/
onStagedChange?: (staged: FilterState) => void
/** Wins over `getResultCount`. `undefined` renders a plain "Show results". */
resultCount?: number
/** What Escape, the close button and the overlay do with staged changes. */
dismissBehavior?: "apply" | "discard"
side?: "left" | "right" | "bottom"
title?: string
/** Trigger label. */
label?: string
/** Applied to the trigger button. */
className?: string
contentClassName?: string
noun?: { one: string; other: string }
/**
* A custom panel layout built from FilterPanel parts. They render inside a
* FilterPanel that holds the *staged* value, so the same layout you use on
* desktop stages here without any changes.
*/
children?: React.ReactNodeSource
components/ecomcn/filter-sheet.tsx"use client"
import * as React from "react"
import { SlidersHorizontal } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
import { FilterPanel, type FilterPanelProps } from "@/components/ecomcn/filter-panel"
import {
countActiveFilters,
isSameFilters,
type FilterState,
} from "@/lib/filter-params"
import { cn } from "@/lib/utils"
export interface FilterSheetProps
extends Pick<FilterPanelProps, "facets" | "currency" | "locale" | "loading"> {
/** The applied filters — usually straight from `useFilterParams`. */
value: FilterState
/** Called once with the staged filters when the sheet applies — never per tap. */
onValueChange: (next: FilterState) => void
/** Result count for the *staged* filters, shown on the apply button. */
getResultCount?: (staged: FilterState) => number | undefined
/**
* Fires on every staged change. When counting needs a request, fetch here
* and pass the answer back as `resultCount`.
*/
onStagedChange?: (staged: FilterState) => void
/** Wins over `getResultCount`. `undefined` renders a plain "Show results". */
resultCount?: number
/** What Escape, the close button and the overlay do with staged changes. */
dismissBehavior?: "apply" | "discard"
side?: "left" | "right" | "bottom"
title?: string
/** Trigger label. */
label?: string
/** Applied to the trigger button. */
className?: string
contentClassName?: string
noun?: { one: string; other: string }
/**
* A custom panel layout built from FilterPanel parts. They render inside a
* FilterPanel that holds the *staged* value, so the same layout you use on
* desktop stages here without any changes.
*/
children?: React.ReactNode
}
export function FilterSheet({
facets,
value,
onValueChange,
getResultCount,
onStagedChange,
resultCount,
dismissBehavior = "apply",
side = "left",
title = "Filters",
label = "Filters",
currency,
locale,
loading,
className,
contentClassName,
noun = { one: "result", other: "results" },
children,
}: FilterSheetProps) {
const [open, setOpen] = React.useState(false)
// Changes made inside the sheet are staged here, not pushed to the URL:
// applying live would re-fetch the listing behind the sheet on every tap.
const [staged, setStaged] = React.useState<FilterState>(value)
const applied = countActiveFilters(value, facets)
const stagedCount = countActiveFilters(staged, facets)
const dirty = !isSameFilters(staged, value, facets)
const count = resultCount ?? (open ? getResultCount?.(staged) : undefined)
const stage = (next: FilterState) => {
setStaged(next)
onStagedChange?.(next)
}
const plural = new Intl.PluralRules(locale)
const results =
count === undefined
? null
: `${new Intl.NumberFormat(locale).format(count)} ${
plural.select(count) === "one" ? noun.one : noun.other
}`
// Only a real change reaches onValueChange, so closing an untouched sheet
// never adds a history entry or fires a request.
const commit = () => {
if (dirty) onValueChange(staged)
}
const handleOpenChange = (next: boolean) => {
if (next) stage(value)
else if (dismissBehavior === "apply") commit()
setOpen(next)
}
return (
<Sheet open={open} onOpenChange={handleOpenChange}>
{/* A real SheetTrigger, not a button that sets `open`: the dialog
primitive only knows where to return focus if it owns the trigger.
Styled with buttonVariants rather than asChild so it works on both
the Radix and Base UI flavours of shadcn/ui. */}
<SheetTrigger
className={cn(buttonVariants({ variant: "outline" }), "gap-2", className)}
>
<SlidersHorizontal className="size-4" aria-hidden />
{label}
{applied > 0 && (
<span className="grid h-5 min-w-5 place-items-center rounded-sm bg-foreground px-1 text-[11px] leading-none text-background tabular-nums">
{applied}
<span className="sr-only"> applied</span>
</span>
)}
</SheetTrigger>
<SheetContent
side={side}
className={cn(
"flex flex-col gap-0 p-0",
side === "bottom" ? "max-h-[88dvh]" : "w-full sm:max-w-sm",
contentClassName
)}
>
<SheetHeader className="border-b px-5 py-4 text-left">
<SheetTitle>{title}</SheetTitle>
<SheetDescription>
{dismissBehavior === "apply"
? "Changes apply when you close this panel."
: "Changes apply when you press Show."}
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-3">
<FilterPanel
hideHeader
title={title}
facets={facets}
value={staged}
onValueChange={stage}
currency={currency}
locale={locale}
loading={loading}
>
{children}
</FilterPanel>
</div>
{/* Outside the scroller, so the footer rule runs edge to edge and the
primary action never scrolls away. */}
<SheetFooter className="mt-0 flex-row gap-2 border-t px-5 py-4">
<Button
type="button"
variant="outline"
className="flex-1"
disabled={stagedCount === 0}
onClick={() => stage({})}
>
Clear
</Button>
<Button
type="button"
className="flex-[2] tabular-nums"
onClick={() => {
commit()
setOpen(false)
}}
>
{results ? `Show ${results}` : "Show results"}
</Button>
<span className="sr-only" aria-live="polite">
{dirty && results ? results : ""}
</span>
</SheetFooter>
</SheetContent>
</Sheet>
)
}