Skip to contentVibraUI
Inputs & filters

Filter chip

A pill saying which filter is applied and to what, with an X that clears it.

The root is a span, not a button: a chip carries two actions — open the filter and clear it — and the HTML parser closes an open button when it meets a nested one, so a nested remove button would hand the browser a different tree than React rendered and break hydration. The body becomes a button only when onClick is given, so a chip is never a tab stop that does nothing. The remove button is named after the filter it clears and stops the click from travelling further. filterChipVariants is exported so a trigger can be styled as a chip without being one — FilterMenu uses it. An active chip is the kit's selected fill, --brand-muted at 500 weight, and its border goes transparent rather than darker: an outline around a box is what focus and an error look like, so a chosen chip is a fill like every chosen thing in the kit, and it keeps that fill under the pointer.

Install

npx shadcn@latest add @vibra/filter-chip

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

Examples

Props

PropTypeDefaultDescription
labelReact.ReactNode—What is being filtered on, e.g. Status.
valueReact.ReactNode—What it is set to; left out, the chip is just its label.
onRemove() => void—Adds an X labelled "Remove <label> filter"; without it there is no X.
onClick() => void—Makes the chip's body a button — reopening the menu it came from, say.
activebooleanfalseMarks the chip as the one currently being edited.
iconReact.ReactNode—Sits before the label; sized to 3.5 unless it sets its own size.
size"sm" | "default""default"sm drops the chip to h-6 for chips inside table rows.
disabledbooleanfalseDims the chip and stops both buttons.

Dependencies

Source

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

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

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

// An active chip is the kit's selected fill — --brand-muted at 500 weight — and
// drops the stroke: an outline around a box is what focus and an error look
// like, and every chosen thing in the kit is a fill. The border stays as a
// transparent hairline, so choosing a chip never moves its neighbours.
const filterChipVariants = cva(
  "inline-flex w-fit shrink-0 items-center rounded-md border text-xs whitespace-nowrap transition-colors",
  {
    variants: {
      size: {
        default: "h-7 gap-1",
        sm: "h-6 gap-0.5",
      },
      active: {
        true: "border-transparent bg-brand-muted font-medium text-foreground",
        false: "border-input bg-background text-foreground",
      },
    },
    defaultVariants: { size: "default", active: false },
  }
)

const filterChipBodyVariants = cva(
  "inline-flex h-full min-w-0 items-center gap-1 rounded-[5px] outline-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
  {
    variants: {
      size: {
        default: "ps-2",
        sm: "ps-1.5",
      },
      interactive: {
        true: "transition-colors focus-ring disabled:pointer-events-none disabled:opacity-50",
        false: "",
      },
      // Without a remove button the body owns the chip's right gutter too.
      padded: {
        true: "pe-2",
        false: "pe-0.5",
      },
    },
    defaultVariants: { size: "default", interactive: false, padded: true },
  }
)

// The root is a <span>, not a <button>, because a chip carries two actions —
// open the filter, clear the filter — and the HTML parser closes an open
// <button> when it meets a nested one, so a nested remove button would give the
// browser a different tree than React rendered and break hydration.
export type FilterChipProps = Omit<React.ComponentProps<"span">, "onClick"> & {
  label: React.ReactNode
  /** What the filter is set to, e.g. "Active"; left out, the chip is just its label. */
  value?: React.ReactNode
  /** Adds an X labelled "Remove <label> filter"; its click never reaches onClick. */
  onRemove?: () => void
  /** Makes the chip's body a button — reopening the menu it came from, say. */
  onClick?: () => void
  /** Marks the chip as the one currently being edited. */
  active?: boolean
  /** Sits before the label; sized to 3.5 unless it sets its own size. */
  icon?: React.ReactNode
  size?: NonNullable<VariantProps<typeof filterChipVariants>["size"]>
  disabled?: boolean
}

/** A pill saying which filter is applied and to what, with an X that clears it. */
function FilterChip({
  className,
  label,
  value,
  onRemove,
  onClick,
  active = false,
  icon,
  size = "default",
  disabled = false,
  ...props
}: FilterChipProps) {
  // A node label has no words to put in the remove button's name.
  const name = typeof label === "string" || typeof label === "number" ? String(label) : null

  const body = (
    <>
      {icon ? (
        <span aria-hidden="true" className="text-muted-foreground">
          {icon}
        </span>
      ) : null}
      <span className="text-muted-foreground">{value === undefined ? label : <>{label}:</>}</span>
      {value === undefined ? null : (
        <span className="min-w-0 truncate font-medium">{value}</span>
      )}
    </>
  )

  return (
    <span
      data-slot="filter-chip"
      data-size={size}
      data-active={active || undefined}
      data-disabled={disabled || undefined}
      className={cn(
        filterChipVariants({ size, active }),
        // A chip whose body does something says so on hover; one that only has an
        // X leaves the hover to the X, and an active one keeps its selected fill
        // under the pointer rather than trading it for the hover plane.
        onClick && !disabled && !active && "hover:bg-accent",
        disabled && "opacity-50",
        className
      )}
      {...props}
    >
      {onClick ? (
        <button
          type="button"
          data-slot="filter-chip-body"
          disabled={disabled}
          onClick={onClick}
          className={filterChipBodyVariants({
            size,
            interactive: true,
            padded: !onRemove,
          })}
        >
          {body}
        </button>
      ) : (
        <span
          data-slot="filter-chip-body"
          className={filterChipBodyVariants({ size, padded: !onRemove })}
        >
          {body}
        </span>
      )}

      {onRemove ? (
        <button
          type="button"
          data-slot="filter-chip-remove"
          aria-label={name ? `Remove ${name} filter` : "Remove filter"}
          disabled={disabled}
          onClick={(event) => {
            // The chip's own body sits beside this button, not around it, but a
            // caller can still wrap the chip in something clickable.
            event.stopPropagation()
            onRemove()
          }}
          className={cn(
            "me-0.5 inline-flex shrink-0 items-center justify-center rounded-[5px] text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground focus-ring disabled:pointer-events-none",
            size === "sm" ? "size-4" : "size-5"
          )}
        >
          <XIcon className="size-3" />
        </button>
      ) : null}
    </span>
  )
}

export { FilterChip, filterChipVariants }