Browse/component
Sort toolbar
The rule above a listing: a result count announced to screen readers once results settle, a native sort select, a density switch and a slot for the mobile filter trigger.
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/sort-toolbarPreview — live
100%
Usage
Above the gridimport { SortToolbar } from "@/components/ecomcn/sort-toolbar"
import { FilterSheet } from "@/components/ecomcn/filter-sheet"
<SortToolbar
total={products.length}
loading={isFetching} // nothing is announced until results settle
sort={sort}
onSortChange={setSort}
density={density}
onDensityChange={setDensity}
// Rendered below lg only, where the sidebar panel is hidden.
filters={<FilterSheet facets={facets} value={filters} onValueChange={setFilters} />}
/>Props
Read straight out of the source at build time, so this cannot drift from the file you install.
SortToolbarProps/** Results for the current filters. */
total: number
/** While true the count dims and nothing is announced. */
loading?: boolean
sort?: string
onSortChange?: (value: string) => void
sortOptions?: SortOption[]
density?: "comfortable" | "compact"
/** Omit to hide the density switch. */
onDensityChange?: (density: "comfortable" | "compact") => void
/** Shown below `lg` only — usually a `<FilterSheet />`. */
filters?: React.ReactNode
locale?: string
noun?: { one: string; other: string }Source
components/ecomcn/sort-toolbar.tsx"use client"
import * as React from "react"
import { ChevronDown, Grid2x2, Grid3x3 } from "lucide-react"
import { cn } from "@/lib/utils"
export interface SortOption {
value: string
label: string
}
export const DEFAULT_SORT_OPTIONS: SortOption[] = [
{ value: "featured", label: "Featured" },
{ value: "newest", label: "Newest" },
{ value: "price-asc", label: "Price, low to high" },
{ value: "price-desc", label: "Price, high to low" },
{ value: "rating", label: "Top rated" },
]
export interface SortToolbarProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
/** Results for the current filters. */
total: number
/** While true the count dims and nothing is announced. */
loading?: boolean
sort?: string
onSortChange?: (value: string) => void
sortOptions?: SortOption[]
density?: "comfortable" | "compact"
/** Omit to hide the density switch. */
onDensityChange?: (density: "comfortable" | "compact") => void
/** Shown below `lg` only — usually a `<FilterSheet />`. */
filters?: React.ReactNode
locale?: string
noun?: { one: string; other: string }
}
export function SortToolbar({
total,
loading,
sort,
onSortChange,
sortOptions = DEFAULT_SORT_OPTIONS,
density = "comfortable",
onDensityChange,
filters,
locale,
noun = { one: "product", other: "products" },
className,
...props
}: SortToolbarProps) {
const id = React.useId()
const label = `${new Intl.NumberFormat(locale).format(total)} ${
new Intl.PluralRules(locale).select(total) === "one" ? noun.one : noun.other
}`
// The visible count changes silently for a screen-reader user, who can't
// see the grid reflow. Announce it — but only once results settle, only
// after the first render, and at most once per burst of filter clicks.
const [announcement, setAnnouncement] = React.useState("")
const settled = React.useRef(label)
const wasLoading = React.useRef(loading)
React.useEffect(() => {
const finishedLoading = wasLoading.current && !loading
wasLoading.current = loading
if (loading) return
// Nothing new to say: same count, and no fetch just completed.
if (!finishedLoading && settled.current === label) return
settled.current = label
const timer = window.setTimeout(() => {
// Alternate a zero-width space so an identical count is re-announced.
setAnnouncement((previous) =>
previous === label ? `${label}\u200b` : label
)
}, 450)
return () => window.clearTimeout(timer)
}, [label, loading])
return (
// Bounded by hairlines top and bottom: it reads as the masthead rule of
// the listing, not as a floating control bar.
<div
className={cn(
"flex flex-wrap items-center gap-x-4 gap-y-3 border-y py-3 text-sm",
className
)}
{...props}
>
<p
className={cn(
"tabular-nums transition-opacity",
loading && "opacity-40"
)}
aria-hidden={loading || undefined}
>
{label}
</p>
<span className="sr-only" role="status" aria-live="polite">
{announcement}
</span>
<div className="ml-auto flex items-center gap-2">
{filters ? <div className="lg:hidden">{filters}</div> : null}
{/* A native select, deliberately: on a phone it opens the OS picker,
which is better than any popover we could draw, and it needs no
positioning, portal or scroll lock. */}
<label htmlFor={`${id}-sort`} className="sr-only sm:not-sr-only sm:text-muted-foreground">
Sort
</label>
<div className="relative">
<select
id={`${id}-sort`}
value={sort ?? sortOptions[0]?.value}
onChange={(event) => onSortChange?.(event.target.value)}
className="h-9 appearance-none rounded-sm border bg-background py-0 pr-8 pl-3 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring dark:[color-scheme:dark]"
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ChevronDown
aria-hidden
className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground"
/>
</div>
{onDensityChange && (
// Compact adds a column from `sm` up. Below that there is no room
// for one, so the switch hides rather than offering a dead control.
<div role="group" aria-label="Grid density" className="hidden items-center border sm:flex">
{(
[
["comfortable", Grid2x2, "Comfortable grid"],
["compact", Grid3x3, "Compact grid"],
] as const
).map(([key, Icon, name]) => (
<button
key={key}
type="button"
aria-label={name}
aria-pressed={density === key}
onClick={() => onDensityChange(key)}
className={cn(
"grid size-9 place-items-center transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
density === key
? "bg-foreground text-background"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="size-4" aria-hidden />
</button>
))}
</div>
)}
</div>
</div>
)
}