A selectable, removable chip with avatar and status-dot slots, Backspace-to-remove, and a width-collapse exit that lets neighbors slide over.
npx shadcn@latest add @paragon/chipAlso installs: avatar
"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}
/>
);
}