Chip
An interactive filter that turns on and off.
Use a row of chips where someone narrows a list by choosing from a fixed set of options. Set Dismissible to yes where each choice should carry its own clear affordance once selected.
Dismissible
Shown state: selected, because that is where this axis shows its effect.
States
In use
Pick a filter and it appears in the applied row. Clear it from either side — the two stay in step because the chip announces every change and the page reacts, which is the whole reason the event exists.
One component, two rows. The markup of a chip is identical in both; only the group around it differs. In the filter row the x deselects and the chip stays put — it is the control, and a control that deletes itself cannot be turned back on. In the applied row the chip is a readout, so clearing it takes the row entry away and switches the source chip off.
Code
The HTML for the live example above is on the page - use your browser's inspector, or copy the CSS here and the markup from the preview. Load post-ui.css once per site and this component works anywhere.
/* ============================================================================
POST UI System — components
HAND-WRITTEN. Not generated: this file encodes the RULES the components follow,
which is not the same thing as whatever the Figma canvas currently looks like.
The contract it implements (variant axes + reasoning) is synced in components.json.
Import order: tokens.css -> type.css -> components.css
NAMING. Every class is `post-` prefixed. This ships into Webflow projects where
`.button`, `.field`, `.label` or `.text-body` are exactly the classes a site
already has, and an unprefixed collision would silently restyle real content.
Block `post-x` · modifier `post-x--y` · state `is-*`.
THE FOUR RULES EVERYTHING BELOW OBEYS
1. Tokens are named by scale, never by consumer — space/m, stroke/m, icon-size/m
and text/md all mean "the middle step".
2. Strokes are altitude, not component — s detail · m component · l section.
3. Optical padding — outer padding is uniform, the LABEL carries space/xs left and
right, and the gap is 0. Text ends up inset by outer+xs while an icon sits at
outer, which is correct: a glyph carries internal whitespace and text does not.
One recipe covers icon-left, icon-right, both, or neither.
4. Control height comes from the ICON TIER, never from text. Every control label
carries min-height = its icon-size token. Without it the icon becomes the
tallest child and toggling an optional icon grows the control — a chip jumped
28 -> 32px exactly this way. With it, toggling an icon changes width only.
currentColor is load-bearing. Outline/ghost controls bind their label fill AND
their stroke to the same `action` token, so the border is written as
`border-color: currentColor` and one colour change moves both.
============================================================================ */
/* ---- chip ---- */
/* INTERACTIVE filter toggle — the counterpart to Badge. Ink and stroke bind the same
token, so the border is currentColor and selected inverts cleanly. */
.post-chip {
display: inline-flex;
align-items: center;
gap: 0;
padding: var(--space-s);
border: var(--stroke-m) solid currentColor;
color: var(--action);
background: none;
cursor: pointer;
font-family: var(--font-post);
font-size: var(--text-2xs);
font-weight: 600;
line-height: 1;
letter-spacing: normal;
text-transform: none;
}
/* Rule 4 again: the label carries the icon tier, so toggling the dismiss x changes
width only — never height. */
.post-chip .post-label { min-height: var(--icon-size-s); }
.post-chip .post-icon { width: var(--icon-size-s); height: var(--icon-size-s); }
/* The dismiss x appears ONLY on a selected chip. An unselected filter has nothing to
clear, so an x on it offers an action that does not exist — and next to the chip's
own toggle it reads as two ways to do one thing. Selecting is what creates something
to dismiss, so that is when the affordance arrives.
display:none rather than visibility:hidden, and the difference is deliberate. Search
Bar's clear button uses visibility because it sits INSIDE a fixed-width field where a
reserved slot stops the text reflowing as you type. A chip has no fixed width: a
reserved slot would just be a dead gap on the right of every unselected chip, so the
padding would look wrong on the common case to protect the rare one. The cost is that
a chip GROWS when selected and nudges its neighbours — acceptable for a filter row,
and the standard behaviour for this control. Do not "fix" that by reserving the slot
without re-reading this.
HEIGHT: the wrapper must not build a text line box. Left as a default block box around
an inline <svg> it was 18px for a 16px glyph — the extra 2px is descender leading below
the baseline — which made a dismissible chip 28px against a text-only chip's 26px, i.e.
exactly the height drift Rule 4 exists to prevent. flex + the icon-tier height pins it
to the same tier the label uses, so the two agree by construction. */
.post-chip [data-post-dismiss] {
display: none;
align-items: center;
height: var(--icon-size-s);
}
/* DISMISSIBLE decides whether selection produces a removable affordance. Renamed from
Select (single|multi) on 2026-08-25 — it named an exclusivity this component never
enforced, while what actually differs is whether an x appears.
no nothing to individually clear, so it NEVER shows a dismiss. This is the
DEFAULT — no modifier needed.
yes each one separately clearable, so selection brings the x. */
.post-chip--dismissible.is-selected [data-post-dismiss] { display: flex; }
.post-chip:hover,
.post-chip.is-hover { background: var(--hover-surface); color: var(--hover-ink); }
.post-chip.is-selected { background: var(--action); color: var(--on-action); border-color: var(--action); }
.post-chip.is-selected:hover,
.post-chip.is-selected.is-hover { background: var(--action-hover); border-color: var(--action-hover); }
.post-chip:focus-visible { outline: 2px solid var(--action); outline-offset: 2px; }