Skip to content
goodtabs UI
BrowseBrowse components

Filter chip

Toggle pills with a label and a count, in a row that scrolls instead of wrapping.

npx shadcn@latest add https://ui.goodtabs.co/r/filter-chip.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/filter-chip

Five categories with counts, controlled from the parent.

Multiple select

Each chip toggles on its own; two start selected.

Without counts

Labels only, as a sort switch.

Usage

import { FilterChip, FilterChipGroup } from "@/components/ui/filter-chip"

<FilterChipGroup aria-label="Category" value={category} onValueChange={setCategory}>
  <FilterChip value="all" count={212}>All</FilterChip>
  <FilterChip value="components" count={48}>Components</FilterChip>
</FilterChipGroup>

Props

PropTypeDefaultDescription
valuestringOn FilterChip: identifies it inside a FilterChipGroup.
countnumber | stringOn FilterChip: mono count after the label, 70% opacity when selected.
selected, defaultSelected, onSelectedChangeboolean, boolean, (selected: boolean) => voidOn a FilterChip used alone: controlled or uncontrolled state.
type"single" | "multiple""single"On FilterChipGroup: one chip at a time, or any number.
value, defaultValue, onValueChangestring or string[] by typeOn FilterChipGroup: controlled or uncontrolled selection.

Source

filter-chip/filter-chip.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

type GroupContextValue = {
  selected: string[];
  toggle: (value: string) => void;
};

const GroupContext = React.createContext<GroupContextValue | null>(null);

type FilterChipProps = Omit<React.ComponentProps<"button">, "value"> & {
  /** Identifies the chip inside a FilterChipGroup. */
  value?: string;
  /** Mono count after the label. */
  count?: number | string;
  /** Controlled selected state when used on its own. */
  selected?: boolean;
  /** Selected on first render when used on its own and uncontrolled. */
  defaultSelected?: boolean;
  /** Called with the next selected state when used on its own. */
  onSelectedChange?: (selected: boolean) => void;
};

/**
 * A toggle pill with a label and an optional count.
 * Inside a FilterChipGroup the group owns the state; alone it manages its own.
 */
function FilterChip({
  value,
  count,
  selected,
  defaultSelected = false,
  onSelectedChange,
  className,
  children,
  onClick,
  ...props
}: FilterChipProps) {
  const group = React.useContext(GroupContext);
  const [internal, setInternal] = React.useState(defaultSelected);
  const isSelected = group && value !== undefined ? group.selected.includes(value) : (selected ?? internal);

  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    onClick?.(event);
    if (event.defaultPrevented) return;
    if (group && value !== undefined) {
      group.toggle(value);
      return;
    }
    const next = !isSelected;
    if (selected === undefined) setInternal(next);
    onSelectedChange?.(next);
  };

  return (
    <button
      type="button"
      aria-pressed={isSelected}
      data-state={isSelected ? "on" : "off"}
      onClick={handleClick}
      className={cn(
        "inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full px-3 text-sm font-medium whitespace-nowrap transition-colors duration-100 ease-out select-none disabled:pointer-events-none disabled:opacity-50",
        isSelected
          ? "bg-primary text-primary-foreground"
          : "text-muted-foreground hover:bg-muted hover:text-foreground",
        className,
      )}
      {...props}
    >
      {children}
      {count !== undefined && (
        <span className={cn("font-mono text-xs font-normal tabular-nums", isSelected && "opacity-70")}>{count}</span>
      )}
    </button>
  );
}

type GroupBaseProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange">;

type FilterChipGroupProps =
  | (GroupBaseProps & {
      /** One chip selected at a time. Clicking the selected chip keeps it selected. */
      type?: "single";
      value?: string;
      defaultValue?: string;
      onValueChange?: (value: string) => void;
    })
  | (GroupBaseProps & {
      /** Any number of chips selected, each one toggling on its own. */
      type: "multiple";
      value?: string[];
      defaultValue?: string[];
      onValueChange?: (value: string[]) => void;
    });

const toArray = (value: string | string[] | undefined) =>
  value === undefined ? [] : Array.isArray(value) ? value : [value];

/**
 * A row of chips that scrolls sideways instead of wrapping.
 * Controlled with value and onValueChange, or uncontrolled with defaultValue.
 */
function FilterChipGroup(allProps: FilterChipGroupProps) {
  const { type = "single", value, defaultValue, onValueChange, className, children, ...props } = allProps;
  const [internal, setInternal] = React.useState<string[]>(() => toArray(defaultValue));
  const selected = value !== undefined ? toArray(value) : internal;
  const notify = onValueChange as ((next: string | string[]) => void) | undefined;

  const toggle = React.useCallback(
    (chip: string) => {
      const isOn = selected.includes(chip);
      let next: string[];
      if (type === "single") {
        if (isOn) return;
        next = [chip];
      } else {
        next = isOn ? selected.filter((v) => v !== chip) : [...selected, chip];
      }
      if (value === undefined) setInternal(next);
      notify?.(type === "single" ? next[0] : next);
    },
    [selected, type, value, notify],
  );

  const context = React.useMemo(() => ({ selected, toggle }), [selected, toggle]);

  return (
    <GroupContext.Provider value={context}>
      <div
        role="group"
        data-slot="filter-chip-group"
        className={cn("-m-1 flex max-w-full items-center gap-1 overflow-x-auto p-1", className)}
        {...props}
      >
        {children}
      </div>
    </GroupContext.Provider>
  );
}

export { FilterChip, FilterChipGroup };

For AI agents

Hand your agent this page, the markdown at /components/filter-chip/prompt.md, or the Copy AI prompt button above.

When to use

  • Above a grid or list to narrow it by category, pricing or tag
  • When the counts help people choose and the set fits in one row
  • For one or many selections that apply at once, with no submit step

When not to use

  • For switching views of the same thing, use segmented-tabs
  • For more than about ten options, use a select or a combobox
  • For navigation between pages, use links

Agent notes

  • Give FilterChipGroup an aria-label; it renders role group and the chips carry aria-pressed
  • Single select keeps one chip selected: clicking the selected chip does nothing, so include an All chip for the empty filter
  • The row scrolls sideways with no wrap; put it in a container with a width or it grows with its parent
  • A demo or parent that passes onValueChange must be a client component