ecomcn
All blocks

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.

See filter sheet in a working shop — the demo store is built from every ecomcn block.Open the demo store
$ npx shadcn@latest add @ecomcn/filter-sheet
Older shadcn CLI, or want the registry pinned?

Register the namespace yourself, once per project, then run the same add command. {name} is literal — the CLI fills it in.

$ npx shadcn@latest registry add @ecomcn=https://ecomcn.vercel.app/r/{name}.json
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 back
const [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.ReactNode

Source

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"

const subscribeNothing = () => () => {}

/**
 * Your locale, or the visitor's once hydrated. The server can't know the
 * visitor's locale, and its HTML must match the first client render — a
 * server in en-IN prints "1,24,000" where an en-US browser prints
 * "124,000" — so until hydration it formats in en-US.
 */
function useFormatLocale(locale?: string) {
  const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false)
  return locale ?? (hydrated ? undefined : "en-US")
}

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: localeProp,
  loading,
  className,
  contentClassName,
  noun = { one: "result", other: "results" },
  children,
}: FilterSheetProps) {
  const locale = useFormatLocale(localeProp)
  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>
  )
}