Badge Overflow
Measures available space and shows badges with an overflow indicator.
Preview
Loading...
import * as React from "react";
import { Cross2Icon } from "@radix-ui/react-icons";
import { Badge } from "@/components/ui/Badge";
import { BadgeOverflow } from "@/components/ui/BadgeOverflow";
import { Button } from "@/components/ui/Button";
export function BadgeOverflowDemo() {
return (
<div className="w-64">
<BadgeOverflow
className="gap-1"
items={[...frameworks]}
renderBadge={(_, label) => <Badge variant="secondary">{label}</Badge>}
/>
</div>
)Installation
Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces
pnpm dlx shadcn@latest add @aura/badge-overflowManual
Install the following dependencies:
pnpm install @radix-ui/react-slotCopy and paste the class names utility into your utils/class-names.ts file.
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy and paste the compose refs utility into your utils/compose-refs.tsx 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 BadgeOverflow component into your components/ui/BadgeOverflow.tsx file.
"use client";
/**
* @description Measures available space and shows badges with an overflow indicator.
*/
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { useComposedRefs } from "@/utils/compose-refs";
import { cn } from "@/utils/class-names";
interface GetBadgeLabel<T> {
/**
* Returns a label string for each badge item.
* Optional for primitive arrays (strings, numbers); required for object arrays.
*/
getBadgeLabel: (item: T) => string;
}
type BadgeOverflowProps<T = string> = React.ComponentProps<"div"> &
(T extends object ? GetBadgeLabel<T> : Partial<GetBadgeLabel<T>>) & {
items: T[];
/** How many lines of badges to show before overflowing. */
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 = false,
className,
style,
ref,
...rootProps
} = props;
const getBadgeLabel = React.useCallback(
(item: T): string => {
if (typeof item === "object" && item !== null && !getBadgeLabelProp) {
throw new Error(
"`getBadgeLabel` is required when using an array of objects",
);
}
return getBadgeLabelProp ? getBadgeLabelProp(item) : String(item);
},
[getBadgeLabelProp],
);
const rootRef = React.useRef<HTMLDivElement | 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 ? Number.parseFloat(gapValue) : 4;
setBadgeGap(Number.isFinite(gap) ? gap : 4);
const paddingLeft = Number.parseFloat(computedStyle.paddingLeft) || 0;
const paddingRight = Number.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 === undefined || item === null) continue;
const label = getBadgeLabel(item);
const badgeWidth = badgeWidths.get(label);
if (!badgeWidth) {
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 {
break;
}
}
return {
visibleItems: visible,
hiddenCount: Math.max(0, items.length - visible.length),
};
}, [
items,
getBadgeLabel,
containerWidth,
lineCount,
badgeGap,
overflowBadgeWidth,
badgeWidths,
]);
const Comp = asChild ? Slot : "div";
const defaultOverflow = (count: number) => (
<div className="inline-flex h-5 shrink-0 items-center rounded-full border border-gray-6 bg-gray-3 px-1.5 text-xs font-semibold text-gray-11">
+{count}
</div>
);
return (
<>
<div
ref={measureRef}
aria-hidden
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) : defaultOverflow(99)}
</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)
: defaultOverflow(hiddenCount)
: null}
</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 };
export type { BadgeOverflowProps };
Usage
WithObjects
export const WithObjects = () => (
<div className="w-64">
<BadgeOverflow
className="gap-1"
items={[...skills]}
getBadgeLabel={(item) => item.name}
renderBadge={(_, label) => <Badge variant="secondary">{label}</Badge>}
/>
</div>
)MultiLine
export const MultiLine = () => (
<div className="w-64">
<BadgeOverflow
className="gap-1"
items={[...frameworks]}
lineCount={2}
renderBadge={(_, label) => <Badge variant="secondary">{label}</Badge>}
/>
</div>
)CustomOverflow
export const CustomOverflow = () => (
<div className="w-64">
<BadgeOverflow
className="gap-1"
items={[...frameworks]}
renderBadge={(_, label) => <Badge variant="secondary">{label}</Badge>}
renderOverflow={(count) => (
<Badge className="bg-accent-9 text-accent-contrast hover:bg-accent-10">
+{count} more
</Badge>
)}
/>
</div>
)InteractiveTags
export const InteractiveTags = () => {
const catalog = [
"React",
"Vue",
"Svelte",
"Solid",
"Angular",
"Qwik",
"Astro",
"Remix",
];
const [tags, setTags] = React.useState(catalog.slice(0, 5));
return (
<div className="flex w-80 flex-col gap-2">
<BadgeOverflow
className="gap-1"
items={tags}
lineCount={2}
renderBadge={(item) => (
<Badge variant="secondary" className="gap-1 pr-1">
{item}
<button
type="button"
aria-label={`Remove ${item}`}
className="inline-flex rounded-full p-0.5 hover:bg-gray-5"
onClick={() =>
setTags((current) => current.filter((tag) => tag !== item))
}
>
<Cross2Icon className="icon" />
</button>
</Badge>
)}
/>
<div className="flex flex-wrap gap-1">
{catalog
.filter((item) => !tags.includes(item))
.map((item) => (
<Button
key={item}
type="button"
variant="pill"
size="sm"
onClick={() => setTags((current) => [...current, item])}
>
Add {item}
</Button>
))}
</div>
</div>
);
};