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
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
| Prop | Type | Default | What it does |
|---|---|---|---|
size | "s" | "m" | "m" | 24 or 28px high. Shared by all three chips. |
lead | ReactNode | none | An icon or a small avatar before the label. Hidden from assistive tech; the label carries the meaning. |
pressed | boolean | none | Chip only. Makes it a filter chip and controls whether it is on. |
defaultPressed | boolean | none | Chip only. Makes it a filter chip that keeps its own state, starting here. |
onPressedChange | (pressed: boolean) => void | none | Chip only. Called with the new state when a filter chip is toggled. |
...rest | ComponentProps<"button"> | none | Chip only. Every native button attribute, including onClick and disabled. |
LinkChip ...rest | ComponentProps<"a"> | none | Every native anchor attribute, including href. |
RemovableChip onRemove | () => void | none | Called when the remove button is pressed. Required. |
RemovableChip removeLabel | string | none | Names the remove button. Defaults to Remove and the label when the label is a string. |
RemovableChip disabled | boolean | false | Disables the remove button and dims the chip. |
RemovableChip ...rest | ComponentProps<"span"> | none | Every native span attribute, applied to the root. |
Anatomy
| root | .eq-chip | A button for Chip, an anchor for LinkChip, a span for RemovableChip. |
|---|---|---|
| check | .eq-chip-check | Filter chips only. Takes no room until pressed, then opens and draws its stroke. |
| lead | .eq-chip-lead | Sizes an Avatar inside it to 18px, or 16px in a small chip. |
| label | .eq-chip-label | Truncates with an ellipsis if the chip is squeezed. |
| remove | .eq-chip-remove | RemovableChip only. A separate labelled button with a 24px target. |
Keyboard
| EnterSpace | Toggles a filter chip, or presses the remove button when it has focus. |
|---|---|
| Enter | Follows a link chip. |
| Tab | Moves 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;
}
}