Skip to contentVibraUI
Data display

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-list

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

Examples

Props

PropTypeDefaultDescription
tags(string | { label: string; value?: string; color?: ChartToken })[]—The labels. The object form adds a removal value and a palette colour.
maxnumberevery tagHow 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.
classNamestring—Merged onto the div root; the remaining div props are spread onto it too.

Dependencies

Source

components/ui/tag-list.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"
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 }