Skip to contentVibraUI
Inputs & filters

Quick filters

A row of one-tap filter pills — the two or three views a table is usually looked at through.

A tablist with roving focus: only the selected pill is in the tab order and the arrow keys move the selection, wrapping at both ends. Controlled only — it holds no state of its own. The first pill stands in for the tab stop when value matches nothing, so the row is always reachable. Counts are tabular-nums rather than mono, which this system keeps for IDs and codes. The chosen pill is the kit's selected fill, --brand-muted with its label in ink — the plane a chosen row, chip or toggle wears — not the solid accent, which is the default Button's; an unchosen pill keeps its label in the quieter tier and hovers on half the muted plane, so the pointer never paints what selected paints, and every pill is 500 weight so choosing one never reflows the row.

Install

npx shadcn@latest add @vibra/quick-filters

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
options{ value: string; label: React.ReactNode; count?: number }[]—The pills, in the order they are shown.
valuestring—The selected pill's value.
onValueChange(value: string) => void—Called with the newly selected value, from a click or an arrow key.
size"sm" | "default""default"sm drops the pills to h-7 and the labels to text-xs.
aria-labelstring"Quick filters"Names the tablist, e.g. Invoice status.

Dependencies

Source

components/ui/quick-filters.tsx
"use client"

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const quickFiltersVariants = cva("flex flex-wrap items-center", {
  variants: {
    size: {
      default: "gap-1.5",
      sm: "gap-1",
    },
  },
  defaultVariants: { size: "default" },
})

// The chosen pill wears the kit's selected fill, --brand-muted, with its label
// in ink — the plane a selected row, chip or toggle takes — not the solid
// accent, which is the default Button's fill. An unchosen pill keeps its label
// in the quieter tier and the pointer gives it half the muted plane, so hover
// never paints what selected paints. The weight is 500 on every pill, so
// choosing one never reflows the row.
const quickFilterVariants = cva(
  "inline-flex shrink-0 items-center rounded-md font-medium whitespace-nowrap transition-colors select-none focus-ring",
  {
    variants: {
      size: {
        default: "h-8 gap-1.5 px-2.5 text-sm",
        sm: "h-7 gap-1 px-2 text-xs",
      },
      selected: {
        true: "bg-brand-muted text-foreground",
        false: "text-muted-foreground hover:bg-muted/50",
      },
    },
    defaultVariants: { size: "default", selected: false },
  }
)

export type QuickFilterOption = {
  value: string
  label: React.ReactNode
  /** How many rows the filter would leave; shown in a quiet badge after the label. */
  count?: number
}

export type QuickFiltersProps = React.ComponentProps<"div"> & {
  options: QuickFilterOption[]
  value: string
  onValueChange: (value: string) => void
  size?: NonNullable<VariantProps<typeof quickFiltersVariants>["size"]>
  "aria-label"?: string
}

/** A row of one-tap filter pills — the two or three views a table is usually looked at through. */
function QuickFilters({
  className,
  options,
  value,
  onValueChange,
  size = "default",
  "aria-label": ariaLabel = "Quick filters",
  ...props
}: QuickFiltersProps) {
  const tabRefs = React.useRef(new Map<string, HTMLButtonElement | null>())

  // The first pill stands in for the tab stop when `value` matches nothing, so
  // the row is always reachable by keyboard.
  const tabStop = options.some((option) => option.value === value) ? value : options[0]?.value

  function move(from: number, direction: 1 | -1) {
    const count = options.length
    if (count === 0) return
    const next = options[(((from + direction) % count) + count) % count]
    onValueChange(next.value)
    tabRefs.current.get(next.value)?.focus()
  }

  function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, index: number) {
    if (event.key === "ArrowRight" || event.key === "ArrowDown") {
      event.preventDefault()
      move(index, 1)
    } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
      event.preventDefault()
      move(index, -1)
    }
  }

  return (
    <div
      data-slot="quick-filters"
      data-size={size}
      role="tablist"
      aria-label={ariaLabel}
      className={cn(quickFiltersVariants({ size }), className)}
      {...props}
    >
      {options.map((option, index) => {
        const selected = option.value === value
        return (
          <button
            key={option.value}
            ref={(node) => {
              tabRefs.current.set(option.value, node)
            }}
            type="button"
            role="tab"
            aria-selected={selected}
            data-slot="quick-filter"
            data-selected={selected || undefined}
            tabIndex={option.value === tabStop ? 0 : -1}
            onClick={() => onValueChange(option.value)}
            onKeyDown={(event) => handleKeyDown(event, index)}
            className={quickFilterVariants({ size, selected })}
          >
            {option.label}
            {option.count === undefined ? null : (
              <span
                data-slot="quick-filter-count"
                className={cn(
                  "rounded px-1 text-xs tabular-nums",
                  selected ? "bg-background text-foreground" : "bg-muted text-muted-foreground"
                )}
              >
                {option.count}
              </span>
            )}
          </button>
        )
      })}
    </div>
  )
}

export { QuickFilters, quickFilterVariants, quickFiltersVariants }