Tag list
A row of labels, with anything past a cap folded into a chip that opens the rest.
A client component: the overflow chip opens a popover. A tag is a plain string or an object; the object form carries a value, which is what onRemove receives, so a label can be renamed without breaking what it removes. Each remove button is named "Remove <label>", so a screen reader hears which tag it drops rather than a row of identical X buttons. A colour names one of the eight chart tokens and paints a dot before the label; the token is checked against the palette list rather than looked up directly, so a stray string cannot reach through to Object's prototype. An empty list renders nothing at all, not an empty row.
Install
npx shadcn@latest add @vibra/tag-listNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { TagList, type TagListTag } from "@/components/ui/tag-list"
const INITIAL: TagListTag[] = [
{ label: "billing", color: "chart-1" },
{ label: "regression", color: "chart-4" },
{ label: "needs-repro", color: "chart-3" },
{ label: "safari", color: "chart-5" },
{ label: "p1", color: "chart-2" },
]
export default function TagListDemo() {
const [tags, setTags] = React.useState(INITIAL)
return (
<div className="flex w-full max-w-lg flex-col gap-5">
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">Labels on VIB-482</p>
<TagList
tags={tags}
onRemove={(value) =>
setTags((current) =>
current.filter((tag) => (typeof tag === "string" ? tag : tag.label) !== value)
)
}
/>
{tags.length === 0 ? (
<button
type="button"
onClick={() => setTags(INITIAL)}
className="w-fit text-sm text-muted-foreground underline underline-offset-4"
>
Put them back
</button>
) : null}
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">Capped at three</p>
<TagList tags={INITIAL} max={3} variant="outline" />
</div>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| tags | (string | { label: string; value?: string; color?: ChartToken })[] | — | The labels. The object form adds a removal value and a palette colour. |
| max | number | every tag | How many tags to show before the rest move into a "+N" popover. |
| onRemove | (value: string) => void | — | Adds a remove button to every tag, named after the tag it removes. |
| size | "sm" | "default" | "default" | sm drops the badges to h-4.5 and 11px. |
| variant | "secondary" | "outline" | "secondary" | Passed through to the Badge primitive each tag renders as. |
| className | string | — | Merged onto the div root; the remaining div props are spread onto it too. |
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"
import { Badge, badgeVariants } from "@/components/ui/badge"
import { CHART_BG, isChartToken, type ChartToken } from "@/components/ui/percentage-bar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
export type TagListTag =
| string
| {
label: string
/** What `onRemove` receives; defaults to the label. */
value?: string
/** Paints a dot before the label from the chart palette. */
color?: ChartToken
}
type ResolvedTag = { label: string; value: string; color?: ChartToken }
function resolveTag(tag: TagListTag): ResolvedTag {
if (typeof tag === "string") return { label: tag, value: tag }
return { label: tag.label, value: tag.value ?? tag.label, color: tag.color }
}
const tagListVariants = cva("flex flex-wrap items-center", {
variants: {
size: {
default: "gap-1.5 [&_[data-slot=tag-list-tag]]:max-w-48",
sm: "gap-1 [&_[data-slot=tag-list-tag]]:h-4.5 [&_[data-slot=tag-list-tag]]:max-w-40 [&_[data-slot=tag-list-tag]]:px-1.5 [&_[data-slot=tag-list-tag]]:text-2xs",
},
},
defaultVariants: { size: "default" },
})
export type TagListSize = NonNullable<VariantProps<typeof tagListVariants>["size"]>
// Narrowed from Badge's own union rather than re-typed, so dropping one of the
// two upstream variants is a compile error here rather than a dead class name.
export type TagListVariant = Extract<
NonNullable<VariantProps<typeof badgeVariants>["variant"]>,
"secondary" | "outline"
>
export type TagListProps = React.ComponentProps<"div"> & {
tags: TagListTag[]
/** How many tags to show before the rest move into a "+N" popover. */
max?: number
/** Adds a remove button to every tag, named after the tag it removes. */
onRemove?: (value: string) => void
size?: TagListSize
variant?: TagListVariant
}
/** A row of labels, with anything past `max` folded into a chip that opens the rest. */
function TagList({
className,
tags,
max,
onRemove,
size = "default",
variant = "secondary",
...props
}: TagListProps) {
if (tags.length === 0) return null
const resolved = tags.map(resolveTag)
const limit = max === undefined ? resolved.length : Math.max(0, max)
const shown = resolved.slice(0, limit)
const hidden = resolved.slice(limit)
const renderTag = (tag: ResolvedTag) => (
<Badge key={tag.value} data-slot="tag-list-tag" variant={variant}>
{tag.color ? (
<span
data-slot="tag-list-dot"
aria-hidden="true"
className={cn(
"size-1.5 shrink-0 rounded-full",
CHART_BG[isChartToken(tag.color) ? tag.color : "chart-1"]
)}
/>
) : null}
<span className="truncate">{tag.label}</span>
{onRemove ? (
<button
type="button"
data-slot="tag-list-remove"
aria-label={`Remove ${tag.label}`}
onClick={() => onRemove(tag.value)}
className="-me-0.5 flex shrink-0 rounded-full opacity-70 transition-opacity hover:opacity-100 focus-ring"
>
<XIcon aria-hidden="true" className="size-3" />
</button>
) : null}
</Badge>
)
return (
<div
data-slot="tag-list"
data-size={size}
data-variant={variant}
className={cn(tagListVariants({ size }), className)}
{...props}
>
{shown.map(renderTag)}
{hidden.length > 0 ? (
<Popover>
<PopoverTrigger
render={
<button
type="button"
data-slot="tag-list-overflow"
aria-label={`${hidden.length} more`}
className={cn(
"inline-flex h-5 shrink-0 items-center rounded-4xl bg-muted px-2 text-xs font-medium tabular-nums text-muted-foreground transition-colors hover:bg-accent focus-ring",
size === "sm" && "h-4.5 px-1.5 text-2xs"
)}
>
{`+${hidden.length}`}
</button>
}
/>
{/* COUPLED TO registry/vibra/ui/popover.tsx: PopoverContent lays its
children out as a column with gap-2.5, which is wrong for a wrap
of pills — flex-row and the list's own gap cancel both. If the
popover ever stops stacking its children, drop the reset. */}
<PopoverContent
data-slot="tag-list-overflow-content"
align="start"
className={cn("w-auto max-w-64 flex-row flex-wrap gap-1.5", tagListVariants({ size }))}
>
{hidden.map(renderTag)}
</PopoverContent>
</Popover>
) : null}
</div>
)
}
export { TagList, tagListVariants }