Aura Design System

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-overflow

Manual

Install the following dependencies:

pnpm install @radix-ui/react-slot

Copy 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>
  );
};