Components
Button
The main way to commit to something — submit a form, confirm a booking, open a flow.
Use filled for the one action a page — or a card — is really asking for. Outline is the considered second choice; ghost is for actions that should stay quiet until someone is looking for them.
Watch out. Two filled buttons side by side. Filled means “this is the move”, and two of them means neither is. Size carries the rest of the hierarchy: page-level actions are large, actions inside a card are small.
Sub-brandTheme
Liverendered from the shipped recipe
Style
filled default
outline
ghost
Size
l default
s
States
default
hover
focus
active
disabled
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.
============================================================================ */
/* ---- button ---- */
/* Style filled·outline·ghost × Size l·s × State default/hover/focus/active/disabled
= 30 variants in Figma. Icons are BOOLEANS + instance-swaps, never variants — that
is what keeps it at 30 instead of 120. */
.post-btn {
display: inline-flex;
align-items: center;
gap: 0;
padding: var(--space-s);
border: var(--stroke-m) solid transparent;
font-family: var(--font-post);
font-weight: 500;
line-height: 1;
color: var(--action);
background: none;
cursor: pointer;
}
/* Size sets the type role AND the icon tier together — they are one decision.
l = text/md + icon-size/m · s = text/sm + icon-size/s */
.post-btn--l { font-size: var(--text-md); }
.post-btn--l .post-label { min-height: var(--icon-size-m); }
.post-btn--l .post-icon { width: var(--icon-size-m); height: var(--icon-size-m); }
.post-btn--s { font-size: var(--text-sm); }
.post-btn--s .post-label { min-height: var(--icon-size-s); }
.post-btn--s .post-icon { width: var(--icon-size-s); height: var(--icon-size-s); }
.post-btn--filled {
background: var(--action);
color: var(--on-action);
border-color: var(--action);
}
.post-btn--outline { border-color: currentColor; }
/* Ghost is flush-aligned by pulling the padding back out, so its label lines up with
body text on the same grid — but the padding stays part of the tap target. */
.post-btn--ghost {
border-color: transparent;
margin-inline: calc(-1 * var(--space-s));
}
.post-btn--filled:hover,
.post-btn--filled.is-hover,
.post-btn--filled.is-active {
background: var(--action-hover);
border-color: var(--action-hover);
}
.post-btn--outline:hover,
.post-btn--outline.is-hover,
.post-btn--outline.is-active,
.post-btn--ghost:hover,
.post-btn--ghost.is-hover,
.post-btn--ghost.is-active {
background: var(--hover-surface);
color: var(--hover-ink);
}
/* Active adds a 1px press on top of the hover colours. */
.post-btn:active,
.post-btn.is-active { transform: translateY(1px); }
.post-btn.is-focus,
.post-btn:focus-visible { outline: 2px solid var(--action); outline-offset: 2px; }
.post-btn.is-disabled,
.post-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }