Badge Overflow
A component that intelligently manages badge overflow by measuring available space and displaying only what fits with an overflow indicator.
"use client";
import { Badge } from "@/components/ui/badge";
import { BadgeOverflow } from "@/components/ui/badge-overflow";
const tags = [
"React",
"TypeScript",
"Next.js",
"Tailwind CSS",
"Shadcn UI",
"Radix UI",
"Zustand",
"React Query",
"Prisma",
"PostgreSQL",
];
export function BadgeOverflowDemo() {
return (
<div className="flex w-64 flex-col gap-8">
<div className="flex flex-col gap-3">
<h3 className="font-medium text-sm">Badge Overflow</h3>
<div className="w-64 rounded-md border p-3">
<BadgeOverflow
items={tags}
renderBadge={(_, label) => (
<Badge variant="secondary">{label}</Badge>
)}
/>
</div>
</div>
<div className="flex flex-col gap-3">
<h3 className="font-medium text-sm">
Badge Overflow with Custom Overflow
</h3>
<div className="w-64 rounded-md border p-3">
<BadgeOverflow
items={tags}
renderBadge={(_, label) => <Badge variant="default">{label}</Badge>}
renderOverflow={(count) => (
<Badge variant="secondary" className="bg-muted">
+{count} more
</Badge>
)}
/>
</div>
</div>
</div>
);
}Installation
CLI
npx shadcn@latest add "https://diceui.com/r/badge-overflow"Manual
Install the following dependencies:
npm install @radix-ui/react-slotCopy the refs composition utilities into your lib/compose-refs.ts file.
/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
*/
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
return ref(value);
}
if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === "function") {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };Copy and paste the following code into your project.
"use client";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
interface GetBadgeLabel<T> {
/**
* Callback that returns a label string for each badge item.
* Optional for primitive arrays (strings, numbers), required for object arrays.
* @example getBadgeLabel={(item) => item.name}
*/
getBadgeLabel: (item: T) => string;
}
type BadgeOverflowElement = React.ComponentRef<typeof BadgeOverflow>;
type BadgeOverflowProps<T = string> = React.ComponentProps<"div"> &
(T extends object ? GetBadgeLabel<T> : Partial<GetBadgeLabel<T>>) & {
items: T[];
lineCount?: number;
renderBadge: (item: T, label: string) => React.ReactNode;
renderOverflow?: (count: number) => React.ReactNode;
asChild?: boolean;
};
function BadgeOverflow<T = string>(props: BadgeOverflowProps<T>) {
const {
items,
getBadgeLabel: getBadgeLabelProp,
lineCount = 1,
renderBadge,
renderOverflow,
asChild,
className,
style,
ref,
...rootProps
} = props;
const getBadgeLabel = React.useCallback(
(item: T): string => {
if (typeof item === "object" && !getBadgeLabelProp) {
throw new Error(
"`getBadgeLabel` is required when using array of objects",
);
}
return getBadgeLabelProp ? getBadgeLabelProp(item) : (item as string);
},
[getBadgeLabelProp],
);
const rootRef = React.useRef<BadgeOverflowElement | null>(null);
const composedRef = useComposedRefs(ref, rootRef);
const measureRef = React.useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = React.useState(0);
const [badgeGap, setBadgeGap] = React.useState(4);
const [badgeHeight, setBadgeHeight] = React.useState(20);
const [overflowBadgeWidth, setOverflowBadgeWidth] = React.useState(40);
const [isMeasured, setIsMeasured] = React.useState(false);
const [badgeWidths, setBadgeWidths] = React.useState<Map<string, number>>(
new Map(),
);
React.useLayoutEffect(() => {
if (!rootRef.current || !measureRef.current) return;
function measureContainer() {
if (!rootRef.current || !measureRef.current) return;
const computedStyle = getComputedStyle(rootRef.current);
const gapValue = computedStyle.gap;
const gap = gapValue ? parseFloat(gapValue) : 4;
setBadgeGap(gap);
const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0;
const paddingRight = parseFloat(computedStyle.paddingRight) || 0;
const totalPadding = paddingLeft + paddingRight;
const widthMap = new Map<string, number>();
const measureChildren = measureRef.current.children;
for (let i = 0; i < items.length; i++) {
const child = measureChildren[i] as HTMLElement | undefined;
if (child) {
const label = getBadgeLabel(items[i] as T);
widthMap.set(label, child.offsetWidth);
}
}
setBadgeWidths(widthMap);
const firstBadge = measureChildren[0] as HTMLElement | undefined;
if (firstBadge) {
setBadgeHeight(firstBadge.offsetHeight || 20);
}
const overflowChild = measureChildren[items.length] as
| HTMLElement
| undefined;
if (overflowChild) {
setOverflowBadgeWidth(overflowChild.offsetWidth || 40);
}
const width = rootRef.current.clientWidth - totalPadding;
setContainerWidth(width);
setIsMeasured(true);
}
measureContainer();
const resizeObserver = new ResizeObserver(measureContainer);
resizeObserver.observe(rootRef.current);
return () => {
resizeObserver.disconnect();
};
}, [items, getBadgeLabel]);
const placeholderHeight = React.useMemo(
() => badgeHeight * lineCount + badgeGap * (lineCount - 1),
[badgeHeight, badgeGap, lineCount],
);
const { visibleItems, hiddenCount } = React.useMemo(() => {
if (!containerWidth || items.length === 0 || badgeWidths.size === 0) {
return { visibleItems: items, hiddenCount: 0 };
}
let currentLineWidth = 0;
let currentLine = 1;
const visible: T[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (!item) continue;
const label = getBadgeLabel(item);
const badgeWidth = badgeWidths.get(label);
if (!badgeWidth) {
// Skip items that haven't been measured yet
continue;
}
const widthWithGap = badgeWidth + badgeGap;
const isLastLine = currentLine === lineCount;
const hasMoreItems = i < items.length - 1;
const availableWidth =
isLastLine && hasMoreItems
? containerWidth - overflowBadgeWidth - badgeGap
: containerWidth;
if (currentLineWidth + widthWithGap <= availableWidth) {
currentLineWidth += widthWithGap;
visible.push(item);
} else if (currentLine < lineCount) {
currentLine++;
currentLineWidth = widthWithGap;
visible.push(item);
} else {
// We're on the last line and this badge doesn't fit
break;
}
}
return {
visibleItems: visible,
hiddenCount: Math.max(0, items.length - visible.length),
};
}, [
items,
getBadgeLabel,
containerWidth,
lineCount,
badgeGap,
overflowBadgeWidth,
badgeWidths,
]);
const Comp = asChild ? Slot : "div";
return (
<>
<div
ref={measureRef}
className="pointer-events-none invisible absolute flex flex-wrap"
style={{ gap: badgeGap }}
>
{items.map((item, index) => (
<React.Fragment key={index}>
{renderBadge(item, getBadgeLabel(item))}
</React.Fragment>
))}
{renderOverflow ? (
renderOverflow(99)
) : (
<div className="inline-flex h-5 shrink-0 items-center rounded-md border px-1.5 font-semibold text-xs">
+99
</div>
)}
</div>
{isMeasured ? (
<Comp
data-slot="badge-overflow"
{...rootProps}
ref={composedRef}
className={cn("flex flex-wrap", className)}
style={{
gap: badgeGap,
...style,
}}
>
{visibleItems.map((item, index) => (
<React.Fragment key={index}>
{renderBadge(item, getBadgeLabel(item))}
</React.Fragment>
))}
{hiddenCount > 0 &&
(renderOverflow ? (
renderOverflow(hiddenCount)
) : (
<div className="inline-flex h-5 shrink-0 items-center rounded-md border px-1.5 font-semibold text-xs">
+{hiddenCount}
</div>
))}
</Comp>
) : (
<Comp
data-slot="badge-overflow"
{...rootProps}
ref={composedRef}
className={cn("flex flex-wrap", className)}
style={{
gap: badgeGap,
minHeight: placeholderHeight,
...style,
}}
>
{items
.slice(
0,
Math.min(items.length, lineCount * 3 - (lineCount > 1 ? 1 : 0)),
)
.map((item, index) => (
<React.Fragment key={index}>
{renderBadge(item, getBadgeLabel(item))}
</React.Fragment>
))}
</Comp>
)}
</>
);
}
export { BadgeOverflow };Layout
import { Badge } from "@/components/ui/badge";
import { BadgeOverflow } from "@/components/ui/badge-overflow";
<BadgeOverflow renderBadge={(_, label) => <Badge>{label}</Badge>} />Usage
With Primitive Arrays
When using primitive arrays (strings, numbers), the getBadgeLabel prop is optional. The component will automatically use the item itself as the label.
<BadgeOverflow
items={["React", "TypeScript", "Next.js"]}
renderBadge={(item, label) => <Badge>{label}</Badge>}
/>With Object Arrays
When using object arrays, the getBadgeLabel prop is required to extract the label from each item.
<BadgeOverflow
items={[
{ id: 1, name: "React" },
{ id: 2, name: "TypeScript" },
]}
getBadgeLabel={(item) => item.name}
renderBadge={(item, label) => <Badge>{label}</Badge>}
/>Examples
Multi-line Overflow
Display badges across multiple lines using the lineCount prop.
"use client";
import { Badge } from "@/components/ui/badge";
import { BadgeOverflow } from "@/components/ui/badge-overflow";
const technologies = [
"React",
"TypeScript",
"Next.js",
"Tailwind CSS",
"Shadcn UI",
"Radix UI",
"Zustand",
"React Query",
"Prisma",
"PostgreSQL",
"Docker",
"Kubernetes",
"AWS",
"Vercel",
"GitHub Actions",
];
export function BadgeOverflowMultilineDemo() {
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-3">
<h3 className="font-medium text-sm">Single Line (default)</h3>
<div className="w-64 rounded-md border p-3">
<BadgeOverflow
items={technologies}
renderBadge={(_, label) => (
<Badge variant="secondary">{label}</Badge>
)}
/>
</div>
</div>
<div className="flex flex-col gap-3">
<h3 className="font-medium text-sm">Two Lines</h3>
<div className="w-64 rounded-md border p-3">
<BadgeOverflow
items={technologies}
lineCount={2}
renderBadge={(_, label) => <Badge variant="outline">{label}</Badge>}
/>
</div>
</div>
<div className="flex flex-col gap-3">
<h3 className="font-medium text-sm">Three Lines</h3>
<div className="w-64 rounded-md border p-3">
<BadgeOverflow
items={technologies}
lineCount={3}
renderBadge={(_, label) => <Badge variant="default">{label}</Badge>}
/>
</div>
</div>
</div>
);
}Interactive Tags
Interactive demo showing how to add and remove tags with overflow handling.
"use client";
import { X } from "lucide-react";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { BadgeOverflow } from "@/components/ui/badge-overflow";
interface Tag {
label: string;
value: string;
}
export function BadgeOverflowInteractiveDemo() {
const [tags, setTags] = React.useState<Tag[]>([
{ label: "React", value: "react" },
{ label: "TypeScript", value: "typescript" },
{ label: "Next.js", value: "nextjs" },
{ label: "Tailwind CSS", value: "tailwindcss" },
{ label: "Shadcn UI", value: "shadcn-ui" },
{ label: "Radix UI", value: "radix-ui" },
{ label: "Zustand", value: "zustand" },
{ label: "React Query", value: "react-query" },
{ label: "Prisma", value: "prisma" },
{ label: "PostgreSQL", value: "postgresql" },
{ label: "MySQL", value: "mysql" },
{ label: "MongoDB", value: "mongodb" },
]);
const [input, setInput] = React.useState("");
const onInputChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setInput(event.target.value);
},
[],
);
const onTagAdd = React.useCallback(() => {
if (input.trim()) {
setTags([
...tags,
{
label: input.trim(),
value: input.trim(),
},
]);
setInput("");
}
}, [input, tags]);
const onTagRemove = React.useCallback(
(value: string) => {
setTags(tags.filter((tag) => tag.value !== value));
},
[tags],
);
const onKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
onTagAdd();
}
},
[onTagAdd],
);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<h3 className="font-medium text-sm">Tags with Overflow</h3>
<div className="w-full max-w-80 rounded-md border p-3">
<BadgeOverflow
items={tags}
getBadgeLabel={(tag) => tag.label}
lineCount={2}
renderBadge={(tag, label) => (
<Badge
variant="secondary"
className="cursor-pointer"
onClick={() => onTagRemove(tag.value)}
>
<span>{label}</span>
<X className="size-3" />
</Badge>
)}
renderOverflow={(count) => (
<Badge variant="outline" className="bg-muted">
+{count} more
</Badge>
)}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Input
placeholder="Add a tag..."
className="max-w-64 flex-1"
value={input}
onChange={onInputChange}
onKeyDown={onKeyDown}
/>
<Button type="button" onClick={onTagAdd}>
Add
</Button>
</div>
<div className="flex flex-col gap-px text-balance text-muted-foreground text-sm">
<p>Click on a badge to remove it.</p>
<p>Resize the container to see overflow behavior.</p>
</div>
</div>
);
}API Reference
BadgeOverflow
The component that measures available space and displays badges with overflow indicators.
Prop
Type
Features
Automatic Width Measurement
The component automatically measures badge widths using DOM measurement and caches results for performance. This ensures accurate overflow calculations without manual configuration.
Computed Container Styles
The component automatically extracts container padding, gap, badge height, and overflow badge width from computed styles. This means it adapts seamlessly to your CSS without requiring manual prop configuration.
Multi-line Support
Control how many lines of badges to display using the lineCount prop. The component will intelligently wrap badges across lines while respecting the overflow constraints.
Custom Rendering
Use renderBadge and renderOverflow props to fully customize how badges and overflow indicators are rendered, allowing complete control over styling and behavior.
Performance Optimization
The component renders all badges invisibly to measure their actual widths, then uses those measurements to determine which badges fit within the specified line count. ResizeObserver efficiently responds to container size changes.
Notes
- The component measures actual rendered badges to calculate widths accurately (including icons, custom styling, etc.)
- Container styles (padding, gap, badge height, overflow width) are automatically computed from CSS
- Measurements update automatically when items change or container is resized
- Container must have a defined width for overflow calculations to work