Composites
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 requiredrenderBadgeis 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.0from Dice UI (MIT, © 2024 Sadman Sakib) onto the package’s own primitives and tokens; its sentence became English, with Vietnamese built in, in3.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.
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.
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.
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.
| Contact | Tags | Owner |
|---|---|---|
Alice Nguyen Aurora Logistics | EnterpriseRenewal Q4Security reviewSSOLogisticsChampion EnterpriseRenewal Q4Security review | Mai Tran |
Bao Le Brightline Media | SMBTrial SMBTrial | Duc Pham |
Clara Whitfield Cormorant Foods | EnterpriseProcurementBudget approvedMulti-yearFood & beverage EnterpriseProcurementBudget approved | Mai Tran |
Hoàng Minh Tuấn Harbor Health | HealthcarePilotVietnamExpansionData residency HealthcarePilotVietnam | Linh Vo |
Karin Dahl Kestrel Energy | Sustainability Sustainability | Huy Nguyen |
What the port guarantees
- The chip can be seen. It is the package’s
Badgein itsoutlinevariant, bordered withcontrol-edge— 3.83:1 in light, 4.94:1 in dark. Upstream drew a div with a bareborder, 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 ansr-onlyspan saysoverflowLabel(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.
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
overflowLabelprop — there is nolabelsobject and noDEFAULT_*_LABELSconstant here. Precedence is the usual one: the prop, then the nearest provider, then English. The two built-in tables arecomitorMessagesEn.badgeOverflowandcomitorMessagesVi.badgeOverflow. - “and 3 more” does not say more what. Where the context does not make it obvious, name the thing:
'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
| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | T[] | — | 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) => string | the item itself | Required 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. |
lineCount | number | 1 | Rows to fill before folding. The chip always sits on the last row. |
renderOverflow | (count: number) => ReactNode | Badge variant outline, +N | Your 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) => string | badgeOverflow namespace | What 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. |
style | CSSProperties | gap: 4px | Where the spacing goes. The gap is read from here and written back inline, so a gap class on className never applies. |
className | string | — | Merged onto the visible row, which starts as flex flex-wrap. Its padding is measured and subtracted. |
asChild | boolean | false | Present for parity with upstream, but unusable: the component hands the Slot its badges as children, and a Slot with more than one child throws. |
...rest | div 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-fixedand a column width; in a flex row,min-w-0 flex-1; anywhere else aw-*ormax-w-*. - Spacing goes in
style. The row writes its gap inline — 4px until you say otherwise — and an inline style beats any class. Rendered withclassName="gap-2"the row still carriesstyle="gap:4px". Writestyle={{ 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.
itemsandgetBadgeLabelare 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. renderBadgeruns 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
renderBadgedraws them differently. asChildthrows. 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-hiddenand the sentence issr-onlybeside it. - The measuring row is
visibility: hidden: nothing in it is read or focusable, so an interactive chip fromrenderOverflowexists 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
renderOverflowreplaces 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
Badge
What renderBadge usually returns, and what the default “+N” chip is made of — the outline variant.
Data Table
A tags column in a real list: give the column a width, and the cell folds instead of growing.
CRM pattern
Contact tags and deal labels, in tables and on record cards.
Tasks pattern
Labels on a task row or card, where the room is fixed and the labels are not.
Tags Input
Where the tags are typed. This page is where they are shown.
Combobox
MultiCombobox folds its selected values into the same “+N” shape inside its trigger.