docs / core / chip

Chip

a compact pill for tags, filters and counts. a chip states something, it does not do something, the only interactive part is the optional dismiss. when the whole thing should toggle, reach for ToggleChip.

intensity · recedesrsc · client

provenance

shaped from personal-website

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

tag rows, filter bars, a count beside a headingvalue carries the number, the label stays textnot a click target. when the whole chip toggles, that is ToggleChipa danger chip states a fact. it does not warn or block anything

a11y

the chip is a plain span · the dismiss button is named by removeLabel with a hit area past the glyph · the x icon is aria-hiddenjest-axe

dependencies

class-variance-authority
live demo · try it out
Engineering
customize
tonesemantic color role
sizesm for dense rows
labelthe text inside
valuetrailing segment behind a divider
selectedglow-ring pressed look for active filters
removablerenders the dismiss button
usagecopy
import { Chip } from "usva/primitives/chip";

<Chip tone="accent" onRemove={() => remove(id)}>Engineering</Chip>

props

proptypedefaultnotes
tone"default" | "accent" | "accent-alt" | "success" | "warning" | "danger""default"the color role. tinted border, fill and text from one token.
size"sm" | "md""md"height and padding. sm for dense rows.
selectedbooleanfalsethe glow-ring pressed look, for chips acting as active filters.
valueReactNodea trailing segment behind a divider, tabular for counts.
onRemove() => voidwhen provided, renders the dismiss button.
removeLabelstring"Remove"accessible name for the dismiss button. name the thing being removed.

get it

npx shadcn add https://usva.build/r/chip.jsoncopy
source · components/ui/chip.tsxexactly what this command copiescopy
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";

export const chipVariants = cva(
  cn(
    "inline-flex items-center gap-1.5 rounded-full border font-mono font-medium uppercase tracking-wide",
    "transition-control duration-fast ease-soft",
    "hover:-translate-y-0.5 hover:border-border-strong motion-reduce:transform-none motion-reduce:transition-none",
  ),
  {
    variants: {
      tone: {
        default: "border-border bg-surface-2 text-muted",
        accent:
          "border-accent/25 bg-accent/12 text-accent hover:border-accent/40",
        "accent-alt":
          "border-accent-alt/25 bg-accent-alt/12 text-accent-alt hover:border-accent-alt/40",
        success:
          "border-success/25 bg-success/12 text-success hover:border-success/40",
        warning:
          "border-warning/25 bg-warning/12 text-warning hover:border-warning/40",
        danger:
          "border-danger/25 bg-danger/12 text-danger hover:border-danger/40",
      },
      size: {
        sm: "h-6 px-2 text-[0.6875rem]",
        md: "h-7 px-2.5 text-xs",
      },
      selected: {
        true: "glow-ring border-transparent text-ink hover:border-transparent",
        false: "",
      },
    },
    defaultVariants: { tone: "default", size: "md", selected: false },
  },
);

export interface ChipProps
  extends Omit<React.HTMLAttributes<HTMLSpanElement>, "onError">,
    VariantProps<typeof chipVariants> {
  value?: React.ReactNode;
  onRemove?: () => void;
  removeLabel?: string;
}

export const Chip = React.forwardRef<HTMLSpanElement, ChipProps>(
  (
    {
      className,
      tone,
      size,
      selected,
      value,
      onRemove,
      removeLabel = "Remove",
      children,
      ...props
    },
    ref,
  ) => (
    <span
      ref={ref}
      className={cn(chipVariants({ tone, size, selected }), className)}
      {...props}
    >
      <span className="truncate">{children}</span>
      {value !== undefined ? (
        <span className="border-current/20 border-l pl-1.5 font-semibold text-ink tabular-nums">
          {value}
        </span>
      ) : null}
      {onRemove ? (
        <button
          type="button"
          aria-label={removeLabel}
          onClick={onRemove}
          className={cn(
            "-mr-1 relative ml-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full outline-none",
            "text-current/70 transition-control duration-fast ease-soft",
            "hover:bg-current/10 hover:text-current active:scale-90",
            "focus-visible:ring-focus motion-reduce:transform-none motion-reduce:transition-none",
            "before:absolute before:-inset-2.5 before:content-['']",
          )}
        >
          <RemoveIcon />
        </button>
      ) : null}
    </span>
  ),
);
Chip.displayName = "Chip";

function RemoveIcon() {
  return (
    <svg
      viewBox="0 0 12 12"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.5}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-3 w-3"
      aria-hidden="true"
    >
      <path d="M3 3l6 6M9 3l-6 6" />
    </svg>
  );
}