Badge Overflow

A row of badges that measures its own width and folds whatever does not fit into a “+N” chip — for the labels in a table cell, the tags on a contact card, anywhere a list of chips has to live in a fixed amount of room. It tells a screen reader how many more there are, instead of reading out “plus three”.

Import
import { BadgeOverflow } from '@comitor/ui' — the main entry, with no optional peer. It deliberately does not reuse the data grid’s internal badge-overflow code, which sits behind @tanstack/react-table.
Client
The file starts with "use client" — it measures in the browser. Its required renderBadge is a function, and a function cannot cross from a Server Component into a client one: render it from a client component of yours.
Origin
Ported in 2.1.0 from Dice UI (MIT, © 2024 Sadman Sakib) onto the package’s own primitives and tokens; its sentence became English, with Vietnamese built in, in 3.0.0. See the changelog.

Anatomy

One component, three decisions: what to show (items), how one badge looks (renderBadge), and how many rows it may fill (lineCount). Everything else — the widths, the gap, the room for the chip — it measures.

tsx
import { Badge, BadgeOverflow } from '@comitor/ui'

<BadgeOverflow
  items={task.labels}                                   // what to show, in order
  getBadgeLabel={(label) => label.name}                 // objects only: the text, and the width's cache key
  renderBadge={(label, text) => <Badge variant="secondary">{text}</Badge>}
  lineCount={1}                                         // rows to fill before folding
  renderOverflow={(count) => <MoreLabels count={count} />}  // optional: your own "+N"
/>

// What lands in the DOM:
// <div class="invisible absolute …">       every badge plus a "+99" chip, only to be measured
// <div data-slot="badge-overflow" …>       the badges that fit, then "+N"

How it measures

Every badge is rendered twice. The first copy is a hidden, absolutely positioned row — visibility: hidden, so nothing in it is seen, read aloud or focused — that holds every badge plus a sample “+99” chip. From it the component reads each badge’s width, and from its own row the gap, the padding and the width available. Then it fills the visible row in order: a badge goes in if it fits, and on the last row, while more badges follow, it must also leave room for the chip. The first badge that does not fit ends the row, and it and everything after it become “+N”. A ResizeObserver on the row measures again whenever the row changes width — drag the divider.

Labels · 55% of the row

Design systemAccessibilityTokens
Drag the divider — or focus it and use the arrow keys.

More than one line

lineCount lets the badges wrap onto more rows before any fold; the chip always lands on the last one. Before it has measured — on the server, and in the first client render — the row reserves room for that many rows (20px each, plus the gaps) and shows the first three badges (five for two rows, eight for three), with no chip yet.

EnterpriseRenewal Q4Security review

lineCount={1} in a 288px box.

In a table

The shape it was chosen for: a tags column in a list of CRM contacts. Two things make it work. The table is table-fixed, so the column has a width that does not depend on its content — in an auto table the cell would size itself to the badges it shows, show fewer, and shrink again. And the folded tags stay reachable: renderOverflow turns the chip into a popover listing exactly the ones that did not fit.

ContactTagsOwner

Alice Nguyen

Aurora Logistics

EnterpriseRenewal Q4Security review
Mai Tran

Bao Le

Brightline Media

SMBTrial
Duc Pham

Clara Whitfield

Cormorant Foods

EnterpriseProcurementBudget approved
Mai Tran

Hoàng Minh Tuấn

Harbor Health

HealthcarePilotVietnam
Linh Vo

Karin Dahl

Kestrel Energy

Sustainability
Huy Nguyen

What the port guarantees

  • The chip can be seen. It is the package’s Badge in its outline variant, bordered with control-edge — 3.83:1 in light, 4.94:1 in dark. Upstream drew a div with a bare border, which in this package is the decorative border at 1.16:1: on a transparent chip, close to invisible. Being a Badge, it shares the type, radius and padding of the badges beside it, and the “+N” shape Combobox uses.
  • A screen reader hears a sentence, not a sum. “+3” is read as “plus three” — three what? The visible “+N” is aria-hidden, and next to it an sr-only span says overflowLabel(count): “and 3 more”.
  • No hidden peer. The data grid has its own, internal badge-overflow logic — tablecn’s variant — behind the grid’s optional peer. This component does not reuse it, so importing it never reaches @tanstack/react-table.

Language

BadgeOverflow has one string: the chip’s sentence for screen readers, overflowLabel in the badgeOverflow namespace. It follows the nearest ComitorLocaleProvider — set once, at the root — English by default, Vietnamese built in. Switch the demo: the labels are the app’s own, translated by the app; the sentence under the chip is the package’s.

Design systemAccessibilityTokens

A chip showing +3 is read as “and 3 more”

overflowLabel(12) → “and 12 more”

  • It is a function, (count) => string, because the number sits inside the sentence and word order differs by language: “and 3 more”, “và 3 mục khác”. A function cannot come from a Server Component, so pass the prop from a client component.
  • The override is the overflowLabel prop — there is no labels object and no DEFAULT_*_LABELS constant here. Precedence is the usual one: the prop, then the nearest provider, then English. The two built-in tables are comitorMessagesEn.badgeOverflow and comitorMessagesVi.badgeOverflow.
  • “and 3 more” does not say more what. Where the context does not make it obvious, name the thing:
tsx
'use client'

import { useTranslations } from 'next-intl'
import { Badge, BadgeOverflow } from '@comitor/ui'

// A noun of your own, in your own translations. The prop wins over the provider —
// and, being a function, it has to be created in a client component.
export function ContactTags({ tags }: { tags: string[] }) {
  const t = useTranslations('Contacts')

  return (
    <BadgeOverflow
      items={tags}
      renderBadge={(tag) => <Badge variant="secondary">{tag}</Badge>}
      overflowLabel={(count) => t('moreTags', { count })} // "and 3 more tags"
    />
  )
}

Props

PropTypeDefaultDescription
itemsrequiredT[]—What to show, in order. The folded ones are always the tail — the last count items. An effect dependency: keep the array stable.
renderBadgerequired(item: T, label: string) => ReactNode—Draws one badge. Called for the hidden measuring row as well as the visible one, so it must render the same size in both — and no element in it may carry an id.
getBadgeLabel(item: T) => stringthe item itselfRequired when items are objects (TypeScript enforces it; at runtime it throws). The label is passed to renderBadge and is the key each measured width is cached under. An effect dependency: keep the function stable.
lineCountnumber1Rows to fill before folding. The chip always sits on the last row.
renderOverflow(count: number) => ReactNodeBadge variant outline, +NYour own chip. Also rendered with a count of 99 in the hidden row, to measure it. The screen-reader sentence is not added for you — your chip has to say it.
overflowLabel(count: number) => stringbadgeOverflow namespaceWhat a screen reader hears instead of the default chip’s +N — English “and 3 more”. A function because the number sits inside the sentence. Ignored when renderOverflow is set.
styleCSSPropertiesgap: 4pxWhere the spacing goes. The gap is read from here and written back inline, so a gap class on className never applies.
classNamestring—Merged onto the visible row, which starts as flex flex-wrap. Its padding is measured and subtracted.
asChildbooleanfalsePresent for parity with upstream, but unusable: the component hands the Slot its badges as children, and a Slot with more than one child throws.
...restdiv props—Forwarded to the visible row. The hidden measuring row takes none of them.

Known limitations

  • It needs a width that does not come from its badges. The row measures itself, so if its width depends on what it shows it can talk itself down to a lone chip: fewer badges, narrower row, fewer badges. In a table use table-fixed and a column width; in a flex row, min-w-0 flex-1; anywhere else a w-* or max-w-*.
  • Spacing goes in style. The row writes its gap inline — 4px until you say otherwise — and an inline style beats any class. Rendered with className="gap-2" the row still carries style="gap:4px". Write style={{ gap: 8 }}.
  • The first frame is a guess. Until it has measured — on the server, and in the first client render — it shows the first three badges (five for two rows, eight for three) and no chip. The reserved height assumes 20px rows, the height of a medium Badge.
  • Stable inputs, or it measures on every render. items and getBadgeLabel are effect dependencies: a new array or an inline function makes it re-read every badge’s width whenever the parent renders — per row, in a table. Hoist them, or memoise them.
  • renderBadge runs twice per item. Once in the hidden row, once in the visible one. No ids inside a badge, and nothing that must happen once.
  • Widths are cached by label. Two items with the same label share one measured width, even if renderBadge draws them differently.
  • asChild throws. The row’s children are generated by the component, so a Slot receives several of them. Wrap the component in your own element instead.

Accessibility

  • The visible row is a plain div of your badges followed by the chip. With the default chip a screen reader reads the badges that fit, then “and 7 more” — the “+7” is aria-hidden and the sentence is sr-only beside it.
  • The measuring row is visibility: hidden: nothing in it is read or focusable, so an interactive chip from renderOverflow exists once for the keyboard, not twice.
  • Folded items are gone, not hidden. They are not in the accessibility tree at all — the sentence says how many, never which. When the reader may need them, make them reachable: a popover on the chip, as the table demo does, or the record’s detail view.
  • A chip from renderOverflow replaces the sentence too. If it is a button, let its name start with the visible “+N” so voice control users can say what they see (WCAG 2.5.3, Label in Name) — the table demo’s reads “+3 more tags”.
  • The default chip’s border is control-edge (3.83:1 light, 4.94:1 dark), so its outline meets the 3:1 a component boundary needs. The badges’ own contrast is Badge’s — see its page.

Related