{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tag-overflow","type":"registry:ui","title":"Tag overflow","description":"A row of tags that shows the first few and folds the rest into a \"+N\" count. Choosing the count opens the rest one after another in place, and \"Show less\" folds them back.","dependencies":["clsx","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/badge.tsx","type":"registry:ui","target":"components/ui/badge.tsx","content":"import type { ComponentProps, CSSProperties } from \"react\";\nimport { cn } from \"./lib/cn\";\n\nconst variants = {\n  neutral: \"bg-muted text-muted-foreground\",\n  info: \"bg-brand-subtle text-[color-mix(in_srgb,var(--accent-text)_82%,var(--foreground))]\",\n  success: \"bg-success-subtle text-success\",\n  warning: \"bg-warning-subtle text-warning\",\n  error: \"bg-destructive-subtle text-destructive\",\n};\n\n/* Status survives without colour: each variant has its own shape. */\nconst shapes = {\n  neutral: <path d=\"M2 5h6\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" />,\n  info: <circle cx=\"5\" cy=\"5\" r=\"3.6\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" />,\n  success: <circle cx=\"5\" cy=\"5\" r=\"4\" fill=\"currentColor\" />,\n  warning: (\n    <>\n      <path d=\"M5 1a4 4 0 0 1 0 8z\" fill=\"currentColor\" />\n      <circle cx=\"5\" cy=\"5\" r=\"4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" />\n    </>\n  ),\n  error: <path d=\"M2 2l6 6M8 2 2 8\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" />,\n};\n\n/* Categorical hues for labels that name a kind, not a status (a deal stage, a team, a type): OKLCH hue angles.\n   Lightness and chroma come per theme from tokens.css (--tone-*), so the text holds 4.5:1 on its wash in both. */\nexport const toneHues = { blue: 255, violet: 295, rose: 2, orange: 50, amber: 82, green: 150, teal: 195, slate: 260 } as const;\nexport type BadgeTone = keyof typeof toneHues;\n\nexport type BadgeProps = ComponentProps<\"span\"> & {\n  variant?: keyof typeof variants;\n  /** A categorical hue with a dot, for kinds rather than statuses. Overrides the variant's colour. */\n  tone?: BadgeTone;\n  /** The status shape before the word. On for status variants, off for neutral labels and counts. */\n  shape?: boolean;\n};\n\nexport function Badge({\n  className,\n  variant = \"neutral\",\n  tone,\n  shape = variant !== \"neutral\" && !tone,\n  children,\n  style,\n  ...props\n}: BadgeProps) {\n  return (\n    <span\n      data-slot=\"badge\"\n      data-variant={variant}\n      data-tone={tone}\n      className={cn(\n        \"inline-flex h-[22px] shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full px-2 text-xs leading-4 font-medium [&_svg]:size-3\",\n        (shape || tone) && \"pl-[7px]\",\n        tone\n          ? \"bg-[oklch(var(--tone-bg-l)_var(--tone-bg-c)_var(--tone-h))] text-[oklch(var(--tone-fg-l)_var(--tone-fg-c)_var(--tone-h))]\"\n          : variants[variant],\n        className,\n      )}\n      style={tone ? ({ \"--tone-h\": toneHues[tone], ...style } as CSSProperties) : style}\n      {...props}\n    >\n      {tone && <span data-slot=\"badge-dot\" aria-hidden=\"true\" className=\"size-1.5 shrink-0 rounded-full bg-current\" />}\n      {shape && (\n        <svg data-slot=\"badge-shape\" viewBox=\"0 0 10 10\" aria-hidden=\"true\" className=\"!size-2.5\">\n          {shapes[variant]}\n        </svg>\n      )}\n      {children}\n    </span>\n  );\n}\n"},{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/tag-overflow.tsx","type":"registry:ui","target":"components/ui/tag-overflow.tsx","content":"\"use client\";\nimport { useRef, useState, type ComponentProps, type CSSProperties } from \"react\";\nimport { Badge, type BadgeTone } from \"./badge\";\nimport { cn } from \"./lib/cn\";\nimport \"./tag-overflow.css\";\n\nexport type OverflowTag = { label: string; tone?: BadgeTone };\n\nexport type TagOverflowProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  /** Names the set, e.g. \"Labels\". */\n  label: string;\n  tags: (string | OverflowTag)[];\n  /** Tags shown before the rest fold into a count. */\n  max?: number;\n};\n\n/**\n * A row of tags that shows the first few and folds the rest into a \"+N\" count. Choosing the count opens the rest\n * one after another in place, and \"Show less\" folds them back, with focus kept on the toggle. For table cells,\n * cards and headers where tags must not take over. Under reduced motion the tags appear without the stagger.\n */\nexport function TagOverflow({ label, tags, max = 3, className, ...props }: TagOverflowProps) {\n  const [open, setOpen] = useState(false);\n  const [opened, setOpened] = useState(0); // counts openings, so the new tags replay their entrance each time\n  const toggle = useRef<HTMLButtonElement>(null);\n  const items = tags.map((tag) => (typeof tag === \"string\" ? { label: tag } : tag));\n  const hidden = Math.max(0, items.length - max);\n  const shown = open ? items : items.slice(0, max);\n\n  return (\n    <div role=\"group\" aria-label={label} data-slot=\"tag-overflow\" className={cn(\"flex flex-wrap items-center gap-1.5\", className)} {...props}>\n      {shown.map((tag, i) => (\n        <Badge key={i >= max ? `${opened}-${i}` : i} tone={tag.tone} data-extra={i >= max ? \"\" : undefined} style={{ \"--to-i\": i - max } as CSSProperties}>\n          {tag.label}\n        </Badge>\n      ))}\n      {hidden > 0 && (\n        <button\n          ref={toggle}\n          type=\"button\"\n          data-slot=\"to-toggle\"\n          aria-expanded={open}\n          aria-label={open ? \"Show fewer tags\" : `Show ${hidden} more ${hidden === 1 ? \"tag\" : \"tags\"}`}\n          onClick={() => {\n            if (!open) setOpened((count) => count + 1);\n            setOpen(!open);\n          }}\n        >\n          {open ? \"Show less\" : `+${hidden}`}\n        </button>\n      )}\n    </div>\n  );\n}\n"},{"path":"ui/tag-overflow.css","type":"registry:file","target":"components/ui/tag-overflow.css","content":"/* tag-overflow.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  /* Tag overflow: extra tags pop in one after another; the count is a small pill. */\n  [data-slot=\"tag-overflow\"] [data-slot=\"badge\"][data-extra] { animation: to-in 0.3s var(--ease-settle) calc(var(--to-i) * 45ms) backwards; }\n  [data-slot=\"tag-overflow\"] [data-slot=\"to-toggle\"] { height: 22px; padding: 0 8px; border: 1px dashed var(--border-strong); border-radius: 999px; background: none; color: var(--muted-foreground); font-size: 12px; font-variant-numeric: tabular-nums; cursor: pointer; transition: color var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out); }\n  @media (hover: hover) { [data-slot=\"tag-overflow\"] [data-slot=\"to-toggle\"]:hover { color: var(--foreground); border-color: var(--foreground); } }\n  [data-slot=\"tag-overflow\"] [data-slot=\"to-toggle\"]:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"tag-overflow\"] [data-slot=\"badge\"][data-extra] { animation: none; }\n  }\n  @keyframes to-in { from { opacity: 0; scale: 0.7; } }\n}\n"}],"categories":["motion"],"meta":{"tier":"free","license":"MIT","family":"surfaces","docs":"/docs/components/tag-overflow"}}