# Filter Bar

> Composite filter bar — search, category pills, date-range picker, and results count, each independently controlled or hidden.

A shadcn/ui block from the [@ilinxa](https://ui.ilinxa.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ilinxa/filter-bar).

- Page, with a live preview: https://shadcn.exchange/r/ilinxa/filter-bar
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ilinxa/filter-bar-light-bdd76f29b955d0b9.png
- Registry item: https://ui.ilinxa.com/r/filter-bar.json
- Categories: forms
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.ilinxa.com/r/filter-bar.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `date-fns`, `lucide-react`, `react-day-picker`
- Registry items: `input`, `button`, `popover`, `calendar`

## Files

### `src/registry/components/forms/filter-bar/filter-bar.tsx`

```tsx
"use client";

import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import {
  defaultFormatDate,
  defaultFormatDateRange,
} from "./lib/format-default";
import { ChipRow } from "./parts/chip-row";
import { DateRangePicker } from "./parts/date-range-picker";
import { SearchInput } from "./parts/search-input";
import {
  DEFAULT_LABELS,
  type DateRange,
  type FilterBarCategoryItem,
  type FilterBarProps,
  type FilterBarValue,
} from "./types";

const ALIGN_CLASSES = {
  left: "items-start text-left",
  center: "items-center text-center",
  right: "items-end text-right",
} as const;

const normalizeCategories = (
  input: FilterBarCategoryItem[] | string[] | undefined,
): FilterBarCategoryItem[] => {
  if (!input) return [];
  return input.map((entry) =>
    typeof entry === "string" ? { value: entry, label: entry } : entry,
  );
};

/**
 * FilterBar — composite filter bar with search + category chips + date-range
 * + optional results count. Each sub-control is independently controlled-or-
 * uncontrolled; a combined `onChange` fires on any change.
 */
function FilterBarImpl(props: FilterBarProps) {
  const {
    categories,
    search: controlledSearch,
    defaultSearch = "",
    onSearchChange,
    searchDebounceMs = 250,
    category: controlledCategory,
    defaultCategory = null,
    onCategoryChange,
    dateRange: controlledDateRange,
    defaultDateRange = { from: undefined, to: undefined },
    onDateRangeChange,
    onChange,
    resultsCount,
    align = "center",
    hideSearch = false,
    hideCategories = false,
    hideDateRange = false,
    labels: labelsProp,
    formatDateRange = defaultFormatDateRange,
    formatDate = defaultFormatDate,
    className,
  } = props;

  const labels = useMemo(
    () => ({ ...DEFAULT_LABELS, ...labelsProp }),
    [labelsProp],
  );

  const normalizedCategories = useMemo(
    () => normalizeCategories(categories),
    [categories],
  );

  // ─── Search state ────────────────────────────────────────
  const [internalSearch, setInternalSearch] = useState(defaultSearch);
  const isSearchControlled = controlledSearch !== undefined;
  const search = isSearchControlled ? controlledSearch : internalSearch;

  const handleSearchChange = useCallback(
    (next: string) => {
      if (!isSearchControlled) setInternalSearch(next);
      onSearchChange?.(next);
    },
    [isSearchControlled, onSearchChange],
  );

  // ─── Category state ──────────────────────────────────────
  const [internalCategory, setInternalCategory] = useState<string | null>(
    defaultCategory,
  );
  const isCategoryControlled = controlledCategory !== undefined;
  const category = isCategoryControlled ? controlledCategory : internalCategory;

  const handleCategoryChange = useCallback(
    (next: string | null) => {
      if (!isCategoryControlled) setInternalCategory(next);
      onCategoryChange?.(next);
    },
    [isCategoryControlled, onCategoryChange],
  );

  // ─── Date range state ────────────────────────────────────
  const [internalDateRange, setInternalDateRange] = useState<DateRange>(defaultDateRange);
  const isDateRangeControlled = controlledDateRange !== undefined;
  const dateRange = isDateRangeControlled ? controlledDateRange : internalDateRange;

  const handleDateRangeChange = useCallback(
    (next: DateRange) => {
      if (!isDateRangeControlled) setInternalDateRange(next);
      onDateRangeChange?.(next);
    },
    [isDateRangeControlled, onDateRangeChange],
  );

  // ─── Combined onChange ───────────────────────────────────
  useEffect(() => {
    if (!onChange) return;
    onChange({ search, category, dateRange });
    // We intentionally fire on every change (including initial mount).
    // Consumers wanting initial-render skip should track it themselves.
  }, [search, category, dateRange, onChange]);

  const filtersRowVisible = !hideCategories || !hideDateRange;

  return (
    <div
      className={cn(
        "flex flex-col gap-6",
        ALIGN_CLASSES[align],
        className,
      )}
    >
      {!hideSearch ? (
        <SearchInput
          value={search}
          onChange={handleSearchChange}
          isControlled={isSearchControlled}
          debounceMs={searchDebounceMs}
          placeholder={labels.searchPlaceholder}
          ariaLabel={labels.searchAriaLabel}
          className="w-full max-w-xl"
        />
      ) : null}

      {filtersRowVisible ? (
        <div className="flex flex-wrap items-center justify-center gap-4">
          {!hideCategories && normalizedCategories.length > 0 ? (
            <ChipRow
              items={normalizedCategories}
              value={category}
              onChange={handleCategoryChange}
              allLabel={labels.allLabel}
              ariaLabel={labels.categoriesAriaLabel}
            />
          ) : null}
          {!hideDateRange ? (
            <DateRangePicker
              value={dateRange}
              onChange={handleDateRangeChange}
              buttonText={labels.dateButtonText}
              clearLabel={labels.clearDateLabel}
              clearText={labels.clearDateText}
              formatRange={formatDateRange}
              formatDate={formatDate}
            />
          ) : null}
        </div>
      ) : null}

      {resultsCount !== undefined ? (
        <p
          aria-live="polite"
          className="text-sm text-muted-foreground"
        >
          {labels.resultsCountText(resultsCount)}
        </p>
      ) : null}
    </div>
  );
}

export const FilterBar = memo(FilterBarImpl);
FilterBar.displayName = "FilterBar";

export default FilterBar;

// Re-export the value type for consumers importing from the root file.
export type { FilterBarValue };
```

### `src/registry/components/forms/filter-bar/index.ts`

```ts
export { FilterBar, default } from "./filter-bar";
export type {
  DateRange,
  FilterBarAlign,
  FilterBarCategoryItem,
  FilterBarLabels,
  FilterBarProps,
  FilterBarValue,
} from "./types";
```

### `src/registry/components/forms/filter-bar/types.ts`

```ts
export interface DateRange {
  from?: Date;
  to?: Date;
}

export interface FilterBarValue {
  search: string;
  category: string | null;
  dateRange: DateRange;
}

export interface FilterBarCategoryItem {
  value: string;
  label?: string;
}

export interface FilterBarLabels {
  /** Search placeholder. Default: 'Search…'. */
  searchPlaceholder?: string;
  /** Search input aria-label. Default: 'Search'. */
  searchAriaLabel?: string;
  /** "All" chip label. Default: 'All'. */
  allLabel?: string;
  /** Date trigger label when no range set. Default: 'Filter by date'. */
  dateButtonText?: string;
  /** Aria-label on the clear-date button. Default: 'Clear date filter'. */
  clearDateLabel?: string;
  /** Visible text on the clear-date button. Default: 'Clear date'. */
  clearDateText?: string;
  /** Results count formatter. Default: (n) => `${n} items found`. */
  resultsCountText?: (count: number) => string;
  /** ARIA group label for the chip row. Default: 'Filter by category'. */
  categoriesAriaLabel?: string;
}

export type FilterBarAlign = "left" | "center" | "right";

export interface FilterBarProps {
  /** Categories to render in the chip row. Pass `string[]` as shorthand. */
  categories?: FilterBarCategoryItem[] | string[];

  // ─── Search (controlled-or-uncontrolled) ──────────────
  search?: string;
  defaultSearch?: string;
  onSearchChange?: (search: string) => void;
  /** Debounce in ms for uncontrolled mode. Default: 250. */
  searchDebounceMs?: number;

  // ─── Category (controlled-or-uncontrolled) ────────────
  category?: string | null;
  defaultCategory?: string | null;
  onCategoryChange?: (category: string | null) => void;

  // ─── Date range (controlled-or-uncontrolled) ──────────
  dateRange?: DateRange;
  defaultDateRange?: DateRange;
  onDateRangeChange?: (range: DateRange) => void;

  // ─── Combined emitter ─────────────────────────────────
  onChange?: (value: FilterBarValue) => void;

  // ─── Display ──────────────────────────────────────────
  resultsCount?: number;
  align?: FilterBarAlign;
  hideSearch?: boolean;
  hideCategories?: boolean;
  hideDateRange?: boolean;

  // ─── i18n + theming ───────────────────────────────────
  labels?: FilterBarLabels;
  formatDateRange?: (range: { from: Date; to: Date }) => string;
  formatDate?: (date: Date) => string;

  // ─── Overrides ────────────────────────────────────────
  className?: string;
}

export const DEFAULT_LABELS: Required<FilterBarLabels> = {
  searchPlaceholder: "Search…",
  searchAriaLabel: "Search",
  allLabel: "All",
  dateButtonText: "Filter by date",
  clearDateLabel: "Clear date filter",
  clearDateText: "Clear date",
  resultsCountText: (count) => `${count} items found`,
  categoriesAriaLabel: "Filter by category",
};
```

### `src/registry/components/forms/filter-bar/lib/format-default.ts`

```ts
/**
 * Default date formatters using native `Intl.DateTimeFormat`. Browser-locale
 * aware by default; consumers override via `formatDate` / `formatDateRange`
 * props for full control.
 */

const SHORT = new Intl.DateTimeFormat(undefined, {
  day: "numeric",
  month: "short",
});

const LONG = new Intl.DateTimeFormat(undefined, {
  day: "numeric",
  month: "long",
  year: "numeric",
});

export const defaultFormatDate = (date: Date): string => LONG.format(date);

export const defaultFormatDateRange = ({
  from,
  to,
}: {
  from: Date;
  to: Date;
}): string => `${SHORT.format(from)} - ${SHORT.format(to)}`;
```

### `src/registry/components/forms/filter-bar/parts/search-input.tsx`

```tsx
"use client";

import { useEffect, useState } from "react";
import { Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";

/**
 * Search input with magnifier icon. Internal debounce when running in
 * uncontrolled mode (`isControlled = false`); controlled mode passes
 * value through immediately.
 */
export function SearchInput({
  value,
  onChange,
  isControlled,
  debounceMs,
  placeholder,
  ariaLabel,
  className,
}: {
  value: string;
  onChange: (next: string) => void;
  isControlled: boolean;
  debounceMs: number;
  placeholder: string;
  ariaLabel: string;
  className?: string;
}) {
  // Local state is the source of truth for keystrokes in uncontrolled mode.
  // In controlled mode the prop is the source of truth and `localValue`
  // is unused (so its initial seed doesn't matter).
  const [localValue, setLocalValue] = useState(value);

  // Debounce localValue → upstream onChange in uncontrolled mode only.
  useEffect(() => {
    if (isControlled) return;
    if (localValue === value) return;
    const id = setTimeout(() => onChange(localValue), debounceMs);
    return () => clearTimeout(id);
  }, [localValue, value, isControlled, debounceMs, onChange]);

  return (
    <div role="search" className={cn("relative", className)}>
      <Search
        aria-hidden="true"
        className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground"
      />
      <Input
        type="search"
        value={isControlled ? value : localValue}
        onChange={(event) => {
          const next = event.target.value;
          if (isControlled) onChange(next);
          else setLocalValue(next);
        }}
        placeholder={placeholder}
        aria-label={ariaLabel}
        className="h-14 rounded-xl border-border/50 pl-12 text-lg focus-visible:border-primary"
      />
    </div>
  );
}
```

### `src/registry/components/forms/filter-bar/parts/chip-row.tsx`

```tsx
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { FilterBarCategoryItem } from "../types";

/**
 * Pill-row of category chips. The first chip is the "All" sentinel —
 * clicking it sets value to null (clears the category filter).
 *
 * Active chip uses Button `variant="default"` (filled lime), inactive uses
 * `variant="outline"`. Each chip is a real button with `aria-pressed`.
 */
export function ChipRow({
  items,
  value,
  onChange,
  allLabel,
  ariaLabel,
  className,
}: {
  items: FilterBarCategoryItem[];
  value: string | null;
  onChange: (value: string | null) => void;
  allLabel: string;
  ariaLabel: string;
  className?: string;
}) {
  return (
    <div
      role="group"
      aria-label={ariaLabel}
      className={cn("flex flex-wrap justify-center gap-2", className)}
    >
      <Button
        variant={value === null ? "default" : "outline"}
        size="sm"
        aria-pressed={value === null}
        onClick={() => onChange(null)}
        className="rounded-full"
      >
        {allLabel}
      </Button>
      {items.map((item) => (
        <Button
          key={item.value}
          variant={value === item.value ? "default" : "outline"}
          size="sm"
          aria-pressed={value === item.value}
          onClick={() => onChange(item.value)}
          className="rounded-full"
        >
          {item.label ?? item.value}
        </Button>
      ))}
    </div>
  );
}
```

### `src/registry/components/forms/filter-bar/parts/date-range-picker.tsx`

```tsx
"use client";

import { Calendar as CalendarIcon, ChevronDown, X } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { DateRange } from "../types";

/**
 * Date-range Popover. Renders a 2-month calendar in range mode.
 * Conditional clear-X button appears next to the trigger when a range is set.
 *
 * Composes shadcn Popover + Calendar primitives. Calendar wraps
 * react-day-picker; date-fns is a transitive peer.
 */
export function DateRangePicker({
  value,
  onChange,
  buttonText,
  clearLabel,
  clearText,
  formatRange,
  formatDate,
}: {
  value: DateRange;
  onChange: (next: DateRange) => void;
  buttonText: string;
  clearLabel: string;
  clearText: string;
  formatRange: (range: { from: Date; to: Date }) => string;
  formatDate: (date: Date) => string;
}) {
  const hasRange = value.from !== undefined || value.to !== undefined;

  let triggerText = buttonText;
  if (value.from && value.to) {
    triggerText = formatRange({ from: value.from, to: value.to });
  } else if (value.from) {
    triggerText = formatDate(value.from);
  }

  return (
    <div className="flex items-center gap-2">
      <Popover>
        {/* F-cross-13: no `asChild` — render the trigger as the button directly
            (both Radix + Base UI pass native props through to a <button>). */}
        <PopoverTrigger
          type="button"
          className={cn(buttonVariants({ variant: "outline" }), "gap-2 rounded-full")}
        >
          <CalendarIcon aria-hidden="true" className="h-4 w-4" />
          {triggerText}
          <ChevronDown aria-hidden="true" className="h-4 w-4" />
        </PopoverTrigger>
        <PopoverContent className="w-auto p-0" align="center">
          <Calendar
            mode="range"
            defaultMonth={value.from}
            selected={value.from || value.to ? { from: value.from, to: value.to } : undefined}
            onSelect={(range) =>
              onChange({ from: range?.from, to: range?.to })
            }
            numberOfMonths={2}
          />
        </PopoverContent>
      </Popover>

      {hasRange ? (
        <Button
          variant="ghost"
          size="sm"
          onClick={() => onChange({ from: undefined, to: undefined })}
          aria-label={clearLabel}
          className="gap-1"
        >
          <X aria-hidden="true" className="h-3 w-3" />
          {clearText}
        </Button>
      ) : null}
    </div>
  );
}
```
