Display

Chip

A compact interactive token: a filter you toggle, a value you remove, or a link to a topic.

Built on
<button>aria-pressed<a>
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
2.8 kB
Built from
AvatarIcon
Try its options

Loading its options.

Code
import { Chip } from "@/components/equip/chip";

export default function Demo() {
  return (
    <div role="group" aria-label="Filter issues" style={{ display: "flex", flexWrap: "wrap", gap: 6, justifyContent: "center", maxWidth: 216 }}>
      <Chip defaultPressed>Open</Chip>
      <Chip defaultPressed={false}>Assigned to me</Chip>
      <Chip defaultPressed={false}>Has PR</Chip>
    </div>
  );
}
Your versionchange its options in Look, This part

Examples

Removable with avatars

After a chip is removed, focus moves to the field so keyboard users are not dropped at the top of the page.

Priya RamanTomás FerreiraIngrid HolmKwame Mensah
Code
"use client";

import { useRef, useState } from "react";
import { Avatar } from "@/components/equip/avatar";
import { Field } from "@/components/equip/field";
import { RemovableChip } from "@/components/equip/chip";

const people = ["Priya Raman", "Tomás Ferreira", "Ingrid Holm", "Kwame Mensah"];

export default function Example() {
  const [to, setTo] = useState(people);
  const field = useRef<HTMLInputElement>(null);
  return (
    <div style={{ display: "grid", gap: 8, maxWidth: 360 }}>
      <div role="group" aria-label="Recipients" style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
        {to.map((name) => (
          <RemovableChip
            key={name}
            lead={<Avatar name={name} alt="" />}
            onRemove={() => {
              setTo((list) => list.filter((n) => n !== name));
              field.current?.focus();
            }}
          >
            {name}
          </RemovableChip>
        ))}
      </div>
      <Field id="chip-add-recipient" ref={field} label="Add a recipient" placeholder="Name or email" />
    </div>
  );
}

Links

A link chip is a plain anchor, so it can be opened in a new tab and shows its address like any link.

Code
import { Icon } from "@/components/equip/icon";
import { LinkChip } from "@/components/equip/chip";

export default function Example() {
  return (
    <nav aria-label="Related topics" style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
      <LinkChip href="#topics/accessibility" lead={<Icon name="accessibility" />}>Accessibility</LinkChip>
      <LinkChip href="#topics/performance" lead={<Icon name="gauge" />}>Performance</LinkChip>
      <LinkChip href="#topics/releases" lead={<Icon name="rocket" />}>Release notes</LinkChip>
      <LinkChip href="#topics/billing">Billing</LinkChip>
    </nav>
  );
}

Sizes and disabled

Small chips are 24px high, the smallest a pointer target may be; medium chips match the small control height.

Faro
Faro
Coimbra
Code
"use client";

import { Chip, RemovableChip } from "@/components/equip/chip";

export default function Example() {
  return (
    <div style={{ display: "grid", gap: 12, justifyItems: "start" }}>
      <div style={{ display: "flex", flexWrap: "wrap", gap: 6, alignItems: "center" }}>
        <Chip size="s" defaultPressed>Lisbon</Chip>
        <Chip size="s" defaultPressed={false}>Porto</Chip>
        <RemovableChip size="s" onRemove={() => {}}>Faro</RemovableChip>
      </div>
      <div style={{ display: "flex", flexWrap: "wrap", gap: 6, alignItems: "center" }}>
        <Chip defaultPressed>Lisbon</Chip>
        <Chip defaultPressed={false}>Porto</Chip>
        <RemovableChip onRemove={() => {}}>Faro</RemovableChip>
      </div>
      <div style={{ display: "flex", flexWrap: "wrap", gap: 6, alignItems: "center" }}>
        <Chip defaultPressed disabled>Archived</Chip>
        <Chip defaultPressed={false} disabled>Drafts</Chip>
        <RemovableChip disabled onRemove={() => {}}>Coimbra</RemovableChip>
      </div>
    </div>
  );
}

Props

PropTypeDefaultWhat it does
size"s" | "m""m"24 or 28px high. Shared by all three chips.
leadReactNodenoneAn icon or a small avatar before the label. Hidden from assistive tech; the label carries the meaning.
pressedbooleannoneChip only. Makes it a filter chip and controls whether it is on.
defaultPressedbooleannoneChip only. Makes it a filter chip that keeps its own state, starting here.
onPressedChange(pressed: boolean) => voidnoneChip only. Called with the new state when a filter chip is toggled.
...restComponentProps<"button">noneChip only. Every native button attribute, including onClick and disabled.
LinkChip ...restComponentProps<"a">noneEvery native anchor attribute, including href.
RemovableChip onRemove() => voidnoneCalled when the remove button is pressed. Required.
RemovableChip removeLabelstringnoneNames the remove button. Defaults to Remove and the label when the label is a string.
RemovableChip disabledbooleanfalseDisables the remove button and dims the chip.
RemovableChip ...restComponentProps<"span">noneEvery native span attribute, applied to the root.

Anatomy

root.eq-chipA button for Chip, an anchor for LinkChip, a span for RemovableChip.
check.eq-chip-checkFilter chips only. Takes no room until pressed, then opens and draws its stroke.
lead.eq-chip-leadSizes an Avatar inside it to 18px, or 16px in a small chip.
label.eq-chip-labelTruncates with an ellipsis if the chip is squeezed.
remove.eq-chip-removeRemovableChip only. A separate labelled button with a 24px target.

Keyboard

EnterSpaceToggles a filter chip, or presses the remove button when it has focus.
EnterFollows a link chip.
TabMoves between chips. In a removable chip only the remove button takes focus.

Notes

  • Distinct from badge, which is a static status label with no interaction, and from tag-input, which edits a list of values inside a field.
  • A filter chip is a toggle button with aria-pressed, so assistive tech hears it as on or off; keep its label the same in both states.
  • Pressed is the shared solid fill under solid ink, never the accent, and the check mark means colour is not the only cue.
  • The remove button is named from the label, such as Remove Lisbon, so a row of them can be told apart.
  • After a removal the chip is gone, so move focus to the next chip or to the field that added it.
  • Give a row of filter chips a group role and a label such as Filter issues.
  • Backspace and Delete on a chip are not handled; the remove button is the one way to remove, by pointer or keyboard.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/chip.tsx131 lines
"use client";

import { useState } from "react";
import type { ComponentProps, MouseEvent, ReactNode } from "react";
import "./chip.css";

type ChipLook = {
  /** 24 or 28px high. */
  size?: "s" | "m";
  /** An icon or a small avatar before the label. */
  lead?: ReactNode;
};

function join(...names: (string | undefined)[]) {
  return names.filter(Boolean).join(" ");
}

function Lead({ children }: { children: ReactNode }) {
  return (
    <span className="eq-chip-lead" aria-hidden="true">
      {children}
    </span>
  );
}

export type ChipProps = ComponentProps<"button"> &
  ChipLook & {
    /** Makes it a filter chip and controls its pressed state. */
    pressed?: boolean;
    /** Makes it a filter chip that keeps its own pressed state, starting here. */
    defaultPressed?: boolean;
    /** Called with the new pressed state when a filter chip is toggled. */
    onPressedChange?: (pressed: boolean) => void;
  };

/** A filter chip when pressed or defaultPressed is given; otherwise a plain action chip. */
export function Chip({
  size = "m",
  lead,
  pressed,
  defaultPressed,
  onPressedChange,
  onClick,
  className,
  children,
  ...rest
}: ChipProps) {
  const [own, setOwn] = useState(defaultPressed ?? false);
  const toggle = pressed !== undefined || defaultPressed !== undefined;
  const on = pressed ?? own;

  function handleClick(event: MouseEvent<HTMLButtonElement>) {
    onClick?.(event);
    if (!toggle || event.defaultPrevented) return;
    if (pressed === undefined) setOwn(!on);
    onPressedChange?.(!on);
  }

  return (
    <button
      type="button"
      {...rest}
      {...(toggle ? { "aria-pressed": on } : {})}
      className={join("eq-chip", className)}
      data-size={size}
      data-toggle={toggle || undefined}
      onClick={handleClick}
    >
      {toggle && (
        <svg className="eq-chip-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="m3.5 8.25 2.75 2.75 6-6.5" pathLength="1" />
        </svg>
      )}
      {lead != null && <Lead>{lead}</Lead>}
      <span className="eq-chip-label">{children}</span>
    </button>
  );
}

export type LinkChipProps = ComponentProps<"a"> & ChipLook;

/** A chip that goes somewhere: a topic, a saved view, a related page. */
export function LinkChip({ size = "m", lead, className, children, ...rest }: LinkChipProps) {
  return (
    <a {...rest} className={join("eq-chip", className)} data-size={size}>
      {lead != null && <Lead>{lead}</Lead>}
      <span className="eq-chip-label">{children}</span>
    </a>
  );
}

export type RemovableChipProps = ComponentProps<"span"> &
  ChipLook & {
    /** Called when the remove button is pressed. Move focus somewhere sensible afterwards. */
    onRemove: () => void;
    /** Names the remove button. Defaults to "Remove" and the label, when the label is a string. */
    removeLabel?: string;
    /** Disables the remove button. */
    disabled?: boolean;
  };

/** A value with its own remove button, such as a chosen recipient or an applied filter. */
export function RemovableChip({
  size = "m",
  lead,
  onRemove,
  removeLabel,
  disabled = false,
  className,
  children,
  ...rest
}: RemovableChipProps) {
  const name = removeLabel ?? (typeof children === "string" || typeof children === "number" ? `Remove ${children}` : "Remove");
  return (
    <span
      {...rest}
      className={join("eq-chip", className)}
      data-size={size}
      data-removable=""
      data-disabled={disabled || undefined}
    >
      {lead != null && <Lead>{lead}</Lead>}
      <span className="eq-chip-label">{children}</span>
      <button type="button" className="eq-chip-remove" aria-label={name} disabled={disabled} onClick={onRemove}>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
          <path d="m5 5 6 6M11 5l-6 6" />
        </svg>
      </button>
    </span>
  );
}
components/equip/chip.css210 lines
@layer equip {
  .eq-chip {
    --_h: var(--eq-h-s);
    --_pad: calc(var(--eq-space-3) - 1px);
    --_bg: transparent;
    --_ink: var(--eq-ink);
    --_line: var(--eq-line-strong);

    box-sizing: border-box;
    display: inline-flex;
    flex: none;
    align-items: center;
    gap: var(--eq-space-1);
    max-width: 100%;
    height: var(--_h);
    margin: 0;
    padding-inline: var(--_pad);
    border: 1px solid var(--_line);
    border-radius: var(--eq-radius-s);
    background: var(--_bg);
    color: var(--_ink);
    font: var(--eq-weight) var(--eq-text-s) / 1 var(--eq-font);
    text-decoration: none;
    white-space: nowrap;
    vertical-align: middle;
    user-select: none;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      border-color var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-chip[data-size="s"] {
    --_h: 24px;
    --_pad: calc(var(--eq-space-2) - 1px);

    font-size: var(--eq-text-xs);
  }

  :is(button, a).eq-chip {
    cursor: pointer;
  }

  .eq-chip[aria-pressed="true"] {
    --_bg: var(--eq-solid);
    --_ink: var(--eq-solid-ink);
    --_line: var(--eq-solid);
  }

  @media (hover: hover) {
    :is(button, a).eq-chip:hover:not(:disabled, [aria-pressed="true"]) {
      --_bg: var(--eq-tint);
    }

    .eq-chip[aria-pressed="true"]:hover:not(:disabled) {
      --_bg: color-mix(in srgb, var(--eq-solid) 88%, var(--eq-solid-ink));
    }
  }

  :is(button, a).eq-chip:active:not(:disabled) {
    scale: var(--eq-press);
  }

  :is(button, a).eq-chip:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-chip:disabled,
  .eq-chip[data-disabled] {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .eq-chip-label {
    min-width: 0;
    /* Room for descenders, which overflow: hidden would otherwise clip at line-height 1. */
    line-height: 1.25;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  /* An icon or a small avatar. Avatar reads these sizes when it is given no size of its own. */
  .eq-chip-lead {
    --_avatar-size: 18px;
    --_avatar-text: calc(var(--eq-text-xs) * 0.75);

    display: inline-flex;
    flex: none;
    align-items: center;
    margin-inline-start: calc(var(--eq-space-1) * -1);
    color: var(--eq-ink-2);
  }

  .eq-chip[data-size="s"] .eq-chip-lead {
    --_avatar-size: 16px;
    --_avatar-text: calc(var(--eq-text-xs) * 0.65);
  }

  .eq-chip[aria-pressed="true"] .eq-chip-lead {
    margin-inline-start: 0;
    color: inherit;
  }

  .eq-chip-lead > svg {
    width: 14px;
    height: 14px;
  }

  /* The check takes no room until the chip is pressed, then opens a space and draws its stroke.
     The negative margin cancels the gap while it is closed. */
  .eq-chip-check {
    flex: none;
    width: 0;
    height: 14px;
    margin-inline: 0 calc(var(--eq-space-1) * -1);
    opacity: 0;
    overflow: visible;
    transition:
      width var(--eq-dur) var(--eq-ease),
      margin var(--eq-dur) var(--eq-ease),
      opacity var(--eq-dur) var(--eq-ease);
  }

  .eq-chip-check > path {
    stroke-dasharray: 2 3;
    stroke-dashoffset: 2;
    transition: stroke-dashoffset var(--eq-dur) var(--eq-ease);
  }

  .eq-chip[aria-pressed="true"] .eq-chip-check {
    width: 14px;
    margin-inline: -2px 0;
    opacity: 1;
    transition:
      width var(--eq-dur-move) var(--eq-spring),
      margin var(--eq-dur-move) var(--eq-spring),
      opacity var(--eq-dur) var(--eq-ease);
  }

  .eq-chip[aria-pressed="true"] .eq-chip-check > path {
    stroke-dashoffset: 1;
    transition: stroke-dashoffset var(--eq-dur-move) var(--eq-spring);
  }

  .eq-chip[data-removable] {
    padding-inline-end: 1px;
    gap: 2px;
  }

  .eq-chip[data-removable] .eq-chip-label {
    margin-inline-end: 2px;
  }

  /* 20px to the eye in a 28px chip, 16px in a 24px one; the pseudo-element makes the target 24px. */
  .eq-chip-remove {
    position: relative;
    display: grid;
    flex: none;
    place-items: center;
    width: calc(var(--_h) - 8px);
    height: calc(var(--_h) - 8px);
    margin: 0 2px 0 0;
    padding: 0;
    border: 0;
    border-radius: calc(var(--eq-radius-s) - 2px);
    background: transparent;
    color: var(--eq-ink-2);
    cursor: pointer;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-chip-remove::after {
    content: "";
    position: absolute;
    inset: 50% auto auto 50%;
    width: 24px;
    height: 24px;
    translate: -50% -50%;
  }

  .eq-chip-remove > svg {
    width: 14px;
    height: 14px;
  }

  @media (hover: hover) {
    .eq-chip-remove:hover:not(:disabled) {
      background: var(--eq-tint-strong);
      color: var(--eq-ink);
    }
  }

  .eq-chip-remove:active:not(:disabled) {
    scale: var(--eq-press);
  }

  .eq-chip-remove:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-chip-remove:disabled {
    cursor: not-allowed;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index