Skip to contentVibraUI
Inputs & filters

Filter bar

The row a table's filters live on, with dashed menus that open searchable checklists.

FilterMenu is controlled: it holds only whether its popover is open. Its trigger wears FilterChip's own styles — dashed with a plus while nothing is set, and on the kit's selected fill — --brand-muted at 500 weight, no stroke — with "Status: Active" or "Status: 2 selected" once something is; a set filter keeps that fill under the pointer and only an empty one takes the hover plane. cmdk puts aria-selected on the highlighted row, which is a cursor rather than a choice, so whether an option is checked is its own aria-checked. The search field is hidden rather than dropped when searchable is false, because cmdk drives the arrow keys from it. FilterBar renders its reset only when activeCount is above zero and onClearAll is given.

Install

npx shadcn@latest add @vibra/filter-bar

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

Examples

Props

PropTypeDefaultDescription
FilterBar.activeCountnumber0How many filters are set; the reset appears once it is above zero.
FilterBar.onClearAll() => void—Called by the "Clear all (n)" button; without it the button never appears.
FilterBar.childrenReact.ReactNode—The menus, chips, and search field on the row.
FilterMenu.labelReact.ReactNode—What the menu filters on, e.g. Plan.
FilterMenu.options{ label: React.ReactNode; value: string; count?: number; icon?: React.ReactNode }[]—The choices, with an optional row count shown right-aligned.
FilterMenu.valuestring[]—The values currently checked.
FilterMenu.onValueChange(value: string[]) => void—Called with the whole new selection, and with an empty array from Clear.
FilterMenu.multiplebooleantrueFalse keeps one option at a time and closes the menu on a pick.
FilterMenu.searchablebooleantrueShows the search field; typing still filters the list when it is off.
FilterMenu.iconReact.ReactNode—Replaces the plus sign on the empty trigger.
FilterMenu.size"sm" | "default""default"sm drops the trigger to h-6 to match small chips.

Dependencies

Source

components/ui/filter-bar.tsx
"use client"

import * as React from "react"
import { PlusCircleIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command"
import { filterChipVariants } from "@/components/ui/filter-chip"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Separator } from "@/components/ui/separator"

export type FilterBarProps = React.ComponentProps<"div"> & {
  /** Called by the "Clear all" button; without it the button never appears. */
  onClearAll?: () => void
  /** How many filters are set; the reset appears once it is above zero. */
  activeCount?: number
  children: React.ReactNode
}

/** The row a table's filters live on: menus and chips, wrapping, with one reset at the end. */
function FilterBar({
  className,
  onClearAll,
  activeCount = 0,
  children,
  ...props
}: FilterBarProps) {
  return (
    <div
      data-slot="filter-bar"
      data-active-count={activeCount}
      className={cn("flex flex-wrap items-center gap-2", className)}
      {...props}
    >
      {children}
      {activeCount > 0 && onClearAll ? (
        <Button
          type="button"
          data-slot="filter-bar-clear"
          variant="ghost"
          size="sm"
          onClick={onClearAll}
          className="text-muted-foreground"
        >
          Clear all ({activeCount})
        </Button>
      ) : null}
    </div>
  )
}

export type FilterMenuOption = {
  label: React.ReactNode
  value: string
  /** Rows the option would leave; shown right-aligned in the list. */
  count?: number
  icon?: React.ReactNode
}

export type FilterMenuProps = {
  label: React.ReactNode
  options: FilterMenuOption[]
  value: string[]
  onValueChange: (value: string[]) => void
  /** False keeps one option at a time and closes the menu on a pick. */
  multiple?: boolean
  /** Shows the search field; typing still filters the list when it is off. */
  searchable?: boolean
  /** Replaces the plus sign on the empty trigger. */
  icon?: React.ReactNode
  size?: "sm" | "default"
  className?: string
}

/** A dashed chip that opens a searchable checklist — the filter every dashboard table starts with. */
function FilterMenu({
  label,
  options,
  value,
  onValueChange,
  multiple = true,
  searchable = true,
  icon,
  size = "default",
  className,
}: FilterMenuProps) {
  const [open, setOpen] = React.useState(false)
  const selected = new Set(value)
  const chosen = options.filter((option) => selected.has(option.value))
  const active = value.length > 0

  // One name fits on a chip; past that the count says more in less. The count
  // comes from `value`, not from the options, so a value the options no longer
  // carry still reads as set rather than leaving the chip half-written.
  const summary =
    value.length === 0 ? null : chosen.length === 1 ? chosen[0].label : `${value.length} selected`

  function toggle(optionValue: string) {
    if (!multiple) {
      onValueChange([optionValue])
      setOpen(false)
      return
    }
    onValueChange(
      selected.has(optionValue)
        ? value.filter((item) => item !== optionValue)
        : [...value, optionValue]
    )
  }

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger
        render={
          <button
            type="button"
            data-slot="filter-menu"
            data-size={size}
            data-active={active || undefined}
            className={cn(
              filterChipVariants({ size, active }),
              "gap-1 transition-colors focus-ring [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
              size === "sm" ? "px-1.5" : "px-2",
              // A set filter is a chosen chip: it keeps the selected fill under
              // the pointer, and only an empty one takes the hover plane.
              !active && "border-dashed text-muted-foreground hover:bg-accent",
              className
            )}
          >
            {active ? (
              icon ? (
                <span aria-hidden="true" className="text-muted-foreground">
                  {icon}
                </span>
              ) : null
            ) : (
              <span aria-hidden="true">{icon ?? <PlusCircleIcon />}</span>
            )}
            <span className={active ? "text-muted-foreground" : undefined}>
              {active ? <>{label}:</> : label}
            </span>
            {summary === null ? null : (
              <span className="max-w-28 truncate font-medium">{summary}</span>
            )}
          </button>
        }
      />

      {/* COUPLED TO registry/vibra/ui/popover.tsx: the popover ships a padded
          w-72 panel, and a command list draws its own padding at its own width. */}
      <PopoverContent align="start" className="w-56 gap-0 p-0">
        <Command>
          {/* Hidden rather than dropped: cmdk drives the arrow keys from this
              field, so taking it out would cost the list its keyboard. */}
          <div className={searchable ? undefined : "sr-only"}>
            <CommandInput placeholder={typeof label === "string" ? label : "Search"} />
          </div>
          {/* Options are checked independently, which cmdk does not say on its
              own — see the aria-checked on each item below. */}
          <CommandList aria-multiselectable={multiple || undefined}>
            <CommandEmpty>No options match.</CommandEmpty>
            <CommandGroup>
              {options.map((option) => {
                const isSelected = selected.has(option.value)
                return (
                  <CommandItem
                    key={option.value}
                    // Filter on the label, so the count beside it never becomes
                    // part of what the search box matches.
                    value={typeof option.label === "string" ? option.label : option.value}
                    onSelect={() => toggle(option.value)}
                    // cmdk owns aria-selected, which it puts on the highlighted
                    // option — a cursor, not a choice. Checked state is its own
                    // attribute, so the two never contradict each other.
                    aria-checked={isSelected}
                    data-checked={isSelected ? "true" : undefined}
                  >
                    {option.icon ? (
                      <span aria-hidden="true" className="text-muted-foreground">
                        {option.icon}
                      </span>
                    ) : null}
                    <span className="truncate">{option.label}</span>
                    {option.count === undefined ? null : (
                      <span className="ms-auto text-xs tabular-nums text-muted-foreground">
                        {option.count}
                      </span>
                    )}
                  </CommandItem>
                )
              })}
            </CommandGroup>
          </CommandList>
        </Command>

        {active ? (
          <>
            <Separator />
            <div className="p-1">
              <Button
                type="button"
                data-slot="filter-menu-clear"
                variant="ghost"
                size="sm"
                onClick={() => onValueChange([])}
                className="w-full justify-center text-muted-foreground"
              >
                Clear
              </Button>
            </div>
          </>
        ) : null}
      </PopoverContent>
    </Popover>
  )
}

export { FilterBar, FilterMenu }