ecomcn
All blocks

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}.json

Step 2Add the block

$ npx shadcn@latest add @ecomcn/sort-toolbar
Preview — live
100%

Usage

Above the grid
import { 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>
  )
}