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-chipNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { CreditCardIcon, GlobeIcon } from "lucide-react"
import { FilterChip } from "@/components/ui/filter-chip"
type Applied = { id: string; label: string; value: string; icon?: React.ReactNode }
const APPLIED: Applied[] = [
{ id: "plan", label: "Plan", value: "Enterprise", icon: <CreditCardIcon /> },
{ id: "region", label: "Region", value: "EU West", icon: <GlobeIcon /> },
{ id: "mrr", label: "MRR", value: "over $5,000" },
]
export default function FilterChipDemo() {
const [chips, setChips] = React.useState(APPLIED)
return (
<div className="flex w-full flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
{chips.map((chip) => (
<FilterChip
key={chip.id}
label={chip.label}
value={chip.value}
icon={chip.icon}
onRemove={() => setChips(chips.filter((item) => item.id !== chip.id))}
/>
))}
{chips.length === 0 ? (
<span className="text-sm text-muted-foreground">No filters applied.</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<FilterChip label="Owner" value="Priya Raman" active onClick={() => {}} />
<FilterChip label="Trialing" size="sm" onRemove={() => {}} />
</div>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | React.ReactNode | — | What is being filtered on, e.g. Status. |
| value | React.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. |
| active | boolean | false | Marks the chip as the one currently being edited. |
| icon | React.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. |
| disabled | boolean | false | Dims the chip and stops both buttons. |
Dependencies
Source
"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 }