Dice UI
Components

Badge Overflow

A component that intelligently manages badge overflow by measuring available space and displaying only what fits with an overflow indicator.

API
"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-slot

Copy 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