POST Design System Using the system
Components

Icon Button

A single action carried by an icon alone, with no visible label.

Use it for actions that are genuinely understood without words — close, play, expand — and always give it an accessible name.

Watch out. An icon sitting next to a label is not this component; that lives inside the component the label belongs to.
Sub-brand
Theme
Liverendered from the shipped recipe

Style

circle-filled
circle-outline
ghost default

Size

s default
l
m

States

default
hover

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.
   ============================================================================ */

/* ---- icon-button ---- */
/* Standalone icons ONLY. An icon beside a label lives inside that label's component.
   Circle corners are knowingly round — the wayfinding map-pin heritage, and one of
   the two sanctioned exceptions to POST's sharp corners (Radio is the other). */
.post-iconbtn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: var(--stroke-m) solid transparent;
  background: none;
  color: var(--action);
  cursor: pointer;
  padding: 0;
}
.post-iconbtn .post-icon { width: 100%; height: 100%; }

/* Ghost: the BOX IS THE ICON, not the circle, so the glyph aligns optically with text
   and with grids. The hover halo overflows the bounds via padding + a matching
   negative margin — so the tap target is circle-sized even at rest, while layout
   still sees only the icon. */
.post-iconbtn--ghost {
  border-radius: 50%;
  /* Both of these are load-bearing, and both were missing. Under the border-box default
     the width token is the WHOLE box, so padding + border ate the glyph from the inside:
     at size s that is 16 - 2*8 - 2*2 = a 0px icon, and s/m/l rendered 0/4/12px instead of
     16/24/32. content-box makes the width token mean the ICON, which is what the tier
     table says it means; border-width:0 then keeps the negative margin exact, so layout
     sees the glyph and nothing else. */
  box-sizing: content-box;
  border-width: 0;
  padding: var(--space-s);
  margin: calc(-1 * var(--space-s));
}
.post-iconbtn--ghost.post-iconbtn--s { width: var(--icon-size-s); height: var(--icon-size-s); }
.post-iconbtn--ghost.post-iconbtn--m { width: var(--icon-size-m); height: var(--icon-size-m); }
.post-iconbtn--ghost.post-iconbtn--l { width: var(--icon-size-l); height: var(--icon-size-l); }
.post-iconbtn--ghost:hover,
.post-iconbtn--ghost.is-hover { background: var(--hover-surface); color: var(--hover-ink); }

/* Circle: the diameter still DERIVES from the icon rather than being hand-set —
   circle = icon + 2 x padding, so the tier table (24/32/48/64 holding 16/24/32/48)
   falls out of the tokens instead of being maintained separately.

   Written as an explicit border-box diameter rather than content-box + padding, because
   under content-box the BORDER is added on top of the circle. That made outline (which
   sets stroke/s at size s and stroke/l at size l) and filled (which keeps the base
   stroke/m) render two different diameters at the same tier — 34 vs 36 at s — and pushed
   size l to 52px against a documented 48. The diameter now includes its own border, so
   the two styles agree and the tier table is true.

   Sizing the glyph explicitly matters for the same reason: the shared 100%/100% rule
   would fill the whole circle now that the box is the circle rather than the icon. */
.post-iconbtn--circle-outline,
.post-iconbtn--circle-filled {
  border-radius: 50%;
  box-sizing: border-box;
  /* Size m is NOT a Figma variant — circle is s and l only (14 variants = 3 styles x 3
     sizes x 2 states, minus circle x m). This is the guard rail: without a declared
     width, `width:100%` on the glyph resolved against an indeterminate parent and blew
     the button up to a ~300x150 ellipse. An out-of-spec combination should look wrong,
     not detonate. */
  width:  calc(var(--icon-size-m) + 2 * var(--space-m));
  height: calc(var(--icon-size-m) + 2 * var(--space-m));
}
.post-iconbtn--circle-outline .post-icon,
.post-iconbtn--circle-filled .post-icon {
  width: var(--icon-size-m); height: var(--icon-size-m);
}
.post-iconbtn--circle-outline.post-iconbtn--s,
.post-iconbtn--circle-filled.post-iconbtn--s {
  width:  calc(var(--icon-size-s) + 2 * var(--space-s));
  height: calc(var(--icon-size-s) + 2 * var(--space-s));
}
.post-iconbtn--circle-outline.post-iconbtn--s .post-icon,
.post-iconbtn--circle-filled.post-iconbtn--s .post-icon {
  width: var(--icon-size-s); height: var(--icon-size-s);
}
.post-iconbtn--circle-outline.post-iconbtn--l,
.post-iconbtn--circle-filled.post-iconbtn--l {
  width:  calc(var(--icon-size-l) + 2 * var(--space-m));
  height: calc(var(--icon-size-l) + 2 * var(--space-m));
}
.post-iconbtn--circle-outline.post-iconbtn--l .post-icon,
.post-iconbtn--circle-filled.post-iconbtn--l .post-icon {
  width: var(--icon-size-l); height: var(--icon-size-l);
}
/* Outline weight follows ALTITUDE (rule 2), not the component: the small circle is a
   detail (stroke/s), the large one is section-scale (stroke/l). Buttons sit between
   at stroke/m. */
.post-iconbtn--circle-outline { border-color: currentColor; }
.post-iconbtn--circle-outline.post-iconbtn--s { border-width: var(--stroke-s); }
.post-iconbtn--circle-outline.post-iconbtn--l { border-width: var(--stroke-l); }
.post-iconbtn--circle-filled {
  background: var(--action);
  color: var(--on-action);
  border-color: var(--action);
}
.post-iconbtn--circle-outline:hover,
.post-iconbtn--circle-outline.is-hover { background: var(--hover-surface); color: var(--hover-ink); }
.post-iconbtn--circle-filled:hover,
.post-iconbtn--circle-filled.is-hover { background: var(--action-hover); border-color: var(--action-hover); }
.post-iconbtn:focus-visible,
.post-iconbtn.is-focus { outline: 2px solid var(--action); outline-offset: 2px; }
.post-iconbtn.is-disabled,
.post-iconbtn:disabled { opacity: 0.4; cursor: not-allowed; }