Chip
Buttons

Chip

A selectable, removable chip with avatar and status-dot slots, Backspace-to-remove, and a width-collapse exit that lets neighbors slide over.

Install

npx shadcn@latest add @paragon/chip

Also installs: avatar

chip.tsx

"use client";

import * as React from "react";
import { X } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from "@/registry/paragon/ui/avatar";

export interface ChipProps extends React.ComponentProps<"span"> {
  /** Makes the chip a toggle with aria-pressed semantics. */
  selectable?: boolean;
  /** Controlled selected state. Leave undefined for uncontrolled. */
  selected?: boolean;
  /** Initial state when uncontrolled. */
  defaultSelected?: boolean;
  onSelectedChange?: (selected: boolean) => void;
  /**
   * Shows the remove affordance. Called after the chip finishes its
   * width-collapse exit — remove the item from state here.
   */
  onRemove?: () => void;
  /** Accessible name for the remove button. */
  removeLabel?: string;
  disabled?: boolean;
  /** Skips the exit choreography; onRemove fires immediately. */
  static?: boolean;
  /**
   * Caps the chip width so long labels truncate. Accepts any CSS length
   * (e.g. `"12rem"`, `160`). The label text ellipsizes; the pill's border
   * and focus ring stay fully visible.
   */
  maxWidth?: number | string;
}

/**
 * An interactive chip: optionally selectable (aria-pressed), optionally
 * removable. Removal collapses the chip's column from 1fr to 0fr — the
 * grid-template exception to the no-layout-animation rule — while fading
 * and blurring, so neighbors slide over smoothly; `onRemove` fires when
 * the collapse settles. Backspace or Delete on a focused chip removes it.
 * Compose `ChipAvatar` and `ChipDot` as leading slots.
 */
export function Chip({
  selectable = false,
  selected: controlledSelected,
  defaultSelected = false,
  onSelectedChange,
  onRemove,
  removeLabel = "Remove",
  disabled = false,
  static: isStatic = false,
  maxWidth,
  className,
  children,
  style,
  ...props
}: ChipProps) {
  const reducedMotion = useReducedMotion();
  const [uncontrolledSelected, setUncontrolledSelected] =
    React.useState(defaultSelected);
  const isControlled = controlledSelected !== undefined;
  const isSelected = selectable
    ? isControlled
      ? controlledSelected
      : uncontrolledSelected
    : false;

  const removable = typeof onRemove === "function";
  const [removing, setRemoving] = React.useState(false);
  const removedRef = React.useRef(false);
  const fallbackTimer = React.useRef<ReturnType<typeof setTimeout>>(null);

  React.useEffect(() => {
    return () => {
      if (fallbackTimer.current) clearTimeout(fallbackTimer.current);
    };
  }, []);

  const finishRemove = React.useCallback(() => {
    if (removedRef.current) return;
    removedRef.current = true;
    if (fallbackTimer.current) clearTimeout(fallbackTimer.current);
    onRemove?.();
  }, [onRemove]);

  const startRemove = React.useCallback(() => {
    if (!removable || removing || disabled) return;
    if (isStatic || reducedMotion) {
      finishRemove();
      return;
    }
    setRemoving(true);
    // Safety net in case transitionend is swallowed (hidden ancestor).
    fallbackTimer.current = setTimeout(finishRemove, 320);
  }, [removable, removing, disabled, isStatic, reducedMotion, finishRemove]);

  const toggleSelected = () => {
    const next = !isSelected;
    if (!isControlled) setUncontrolledSelected(next);
    onSelectedChange?.(next);
  };

  const handleRemoveKeys = (event: React.KeyboardEvent) => {
    if (!removable) return;
    if (event.key === "Backspace" || event.key === "Delete") {
      event.preventDefault();
      startRemove();
    }
  };

  return (
    <span
      data-slot="chip"
      data-selected={isSelected || undefined}
      data-removing={removing || undefined}
      onTransitionEnd={(event) => {
        if (
          removing &&
          event.target === event.currentTarget &&
          event.propertyName === "grid-template-columns"
        ) {
          finishRemove();
        }
      }}
      className={cn(
        // The removal collapse animates grid-template-columns 1fr→0fr; only
        // then do we clip, so the pill's border/ring is never cut off in the
        // resting state. Padding keeps the focus ring off the collapse edge.
        "inline-grid [grid-template-columns:1fr] p-px align-middle",
        "transition-[grid-template-columns,opacity,filter] duration-200 ease-(--ease-exit)",
        removing && "overflow-hidden [grid-template-columns:0fr] opacity-0 blur-[2px]",
        className,
      )}
      style={{ maxWidth, ...style }}
      {...props}
    >
      {/* min-w-0 lets the pill shrink inside the grid; no overflow-hidden here
          so the pill's shadow-border ring and focus ring stay fully visible.
          Truncation happens on the label text span, never on the pill. */}
      <span className="flex min-w-0">
        <span
          className={cn(
            "relative flex h-7 min-w-0 max-w-full items-center gap-1.5 rounded-full pr-2 pl-2.5 text-[13px] font-medium whitespace-nowrap select-none",
            "transition-[background-color,color,box-shadow,scale] duration-150 ease-out",
            // A leading avatar sits snug at the pill edge: tighten the left
            // padding instead of pulling the avatar out with a negative margin
            // (which the truncating label group would clip).
            "has-[[data-slot=chip-avatar]]:pl-1",
            !removable && "pr-2.5",
            isSelected
              ? "bg-primary text-primary-foreground"
              : "bg-card text-foreground shadow-border",
            selectable && !isSelected && "hover:shadow-border-hover",
            !isStatic && "has-[[data-chip-trigger]:active]:scale-[0.97]",
            disabled && "pointer-events-none opacity-50",
          )}
        >
          {selectable && (
            <button
              type="button"
              data-chip-trigger=""
              aria-pressed={isSelected}
              disabled={disabled}
              onClick={toggleSelected}
              onKeyDown={handleRemoveKeys}
              className="absolute inset-0 rounded-full outline-none after:absolute after:inset-x-0 after:top-1/2 after:h-10 after:-translate-y-1/2 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
            >
              <span className="sr-only">
                {typeof children === "string" ? children : "Toggle"}
              </span>
            </button>
          )}
          {/* Label group: truncates its own text (overflow clipped here, well
              inside the pill's border) so a long label ellipsizes without ever
              touching the ring. Leading slots stay shrink-0, so avatars/dots
              survive and only the text collapses. */}
          <span className="pointer-events-none relative z-[1] flex min-w-0 items-center gap-1.5 truncate">
            {children}
          </span>
          {removable && (
            <button
              type="button"
              aria-label={removeLabel}
              disabled={disabled}
              onClick={startRemove}
              onKeyDown={handleRemoveKeys}
              className={cn(
                "pressable relative z-[1] -mr-0.5 flex size-4 shrink-0 items-center justify-center rounded-full outline-none",
                "transition-[background-color,color,scale] duration-150 ease-out",
                "after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
                "focus-visible:ring-2 focus-visible:ring-ring",
                isSelected
                  ? "text-primary-foreground/70 hover:bg-primary-foreground/15 hover:text-primary-foreground"
                  : "text-muted-foreground hover:bg-foreground/10 hover:text-foreground",
              )}
            >
              <X className="size-3" aria-hidden />
            </button>
          )}
        </span>
      </span>
    </span>
  );
}

export interface ChipAvatarProps {
  /** Seeds initials and a deterministic pastel tone. */
  name: string;
  src?: string;
  className?: string;
}

/** A 20px leading avatar. The pill tightens its left padding to seat it snug. */
export function ChipAvatar({ name, src, className }: ChipAvatarProps) {
  return (
    <Avatar
      data-slot="chip-avatar"
      size="sm"
      className={cn("size-5 shrink-0 text-[9px]", className)}
    >
      {src ? <AvatarImage src={src} alt="" /> : null}
      <AvatarFallback name={name} />
    </Avatar>
  );
}

export interface ChipDotProps extends React.ComponentProps<"span"> {}

/** A leading status dot. Color it with a text token, e.g. `text-success`. */
export function ChipDot({ className, ...props }: ChipDotProps) {
  return (
    <span
      data-slot="chip-dot"
      aria-hidden
      className={cn("size-2 shrink-0 rounded-full bg-current", className)}
      {...props}
    />
  );
}