Components
Stacked Card
A vertical card — media above, then title, text, tags and actions.
The default card. Use it in grids where the cards sit side by side and the image is doing the work. Actions run filled then outline, so the card names its primary move rather than offering two equal ones.
Watch out. Tags go under the text, never above it — a category read before the title asks someone to hold a label for something they have not been told about yet.
Sub-brandTheme
Liverendered from the shipped recipe
Style
ghost
outline default
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.
============================================================================ */
/* ---- card ---- */
/* THREE components, one stylesheet. Stacked / Horizontal / Horizontal Small differ in
the media RELATIONSHIP, not in their mechanics, so the base carries everything and
each modifier changes only direction, media size and the padding rung.
ONE TOKEN DRIVES BOTH STYLES. `--card-pad` is the outline's padding AND the ghost's
hover bleed, because they are the same measurement seen twice: the outline pays for
that space permanently, the ghost borrows it only while the pointer is down on it.
Writing it once is what stops the two drifting apart — which is exactly what happened
in Figma, where the bleed was hand-set to 8 against an outline padding of 16 and 12.
Figma CANNOT hold this rule: x/y are not bindable to variables, so the plate's inset
is a literal on canvas and goes stale at every breakpoint. This file is authoritative
for the bleed, the same way it is for text/display-inset. */
.post-card {
--card-pad: var(--space-l);
position: relative; /* the ghost plate anchors to this */
/* AND the card must be its own stacking context, or the ghost plate DISAPPEARS. The plate
is `z-index:-1` so it sits behind the card's content; but `position:relative` with
`z-index:auto` does NOT create a stacking context, so that -1 escaped the card entirely
and painted in the ROOT's negative layer - underneath the background of any ancestor
that had one. On a bare page it looked fine. Inside anything with a surface - the
guide's own matrix cell, or any real page section - the hover state vanished and only
the ink changed. `isolation` creates the context without touching layout. */
isolation: isolate;
display: flex;
flex-direction: column;
gap: var(--space-m);
color: var(--text);
text-decoration: none;
/* NO border-radius. POST is a sharp-corner brand; the only sanctioned round corners
in this system are Icon Button's circle and Radio. */
}
/* THE CARD'S MEDIA IS A MEDIA THUMB with its slots switched off - not a second image
implementation living in the card. That is why this rule sets LAYOUT ONLY: no ratio, no
background, no object-fit. The plane, the ratio, the placeholder and the element-level
light/dark all come from `.post-mediathumb`, which already owns them.
<div class="post-card__media post-mediathumb post-mediathumb--landscape"></div>
Before this, the card drew its own 16:9 box with a flat `hover-surface` fill, which is
how the guide ended up showing a solid blue rectangle where the placeholder should be. */
.post-card__media { display: block; width: 100%; }
/* BODY holds everything that is not the media: text, then tags, then actions. The card's
own axis flips between stacked and horizontal, but the body's never does - it is always a
column. Without it, a horizontal card's tags and buttons would land in the ROW, beside the
media, instead of under the title where they belong. */
.post-card__body {
display: flex;
flex-direction: column;
gap: var(--space-m);
min-width: 0;
flex: 1 1 auto;
}
.post-card__text { display: flex; flex-direction: column; gap: var(--space-xs); min-width: 0; }
/* TAGS SIT UNDER THE TEXT COMBO, never above it. A tag is metadata ABOUT the thing the
title names, so it cannot be read before the title without asking someone to hold a
category in mind for something they have not been told about yet. */
.post-card__tags { display: flex; flex-wrap: wrap; gap: var(--space-s); }
/* ACTIONS LAST, and FILLED + OUTLINE.
REVERSED 2026-08-25. This row was outline + ghost, on the argument that the card is
already the primary target and a filled button inside it competes with the thing that
contains it. The call now is the other way: outline + ghost read as two secondary
actions with no clear first move, and a card whose whole point is to be acted on should
say which action that is. Filled leads, outline follows.
What that costs, recorded so it is a known trade and not a surprise: `filled` no longer
means "page-level action" exclusively, so the Button hierarchy is now carried by SIZE
and CONTEXT rather than by style alone - card actions are size s, page actions size l.
On a media card the solid brand block does sit directly under the photograph; if that
reads badly on a real image, this is the decision to revisit first. */
/* ACTIONS SIT FURTHER DOWN THAN THE REST OF THE STACK. The body gaps everything at
space/m, which is right between text and tags - they are both description of the same
thing. Actions are not description, they are the thing you DO, and at the same gap they
read as one more line of metadata. The extra space/s on top is what separates "what this
is" from "what you can do with it" without needing a rule or a heading to say so. */
.post-card__actions {
display: flex;
align-items: center;
gap: var(--space-s);
margin-top: var(--space-s);
}
/* NOTE: as of 2026-08-25 this row is filled + outline, so no ghost button appears in it and
the rule below no longer fires here. It is kept because it is the reasoning for ghost's
negative margin ANYWHERE, and because reverting the action styles would make it live
again. If ghost ever returns to a card row, this is why the margin stays.
LEAVE GHOST'S NEGATIVE MARGIN ALONE in this row. It looks like it should be cancelled -
a -space/s pull on the second button reads like it will eat the gap - and cancelling it is
wrong. The gap is measured between BOXES, but what a person sees is the distance to the
LABEL, and the label sits space/s inside its own box. The pull removes exactly that
doubling:
with the pull Action-1 edge -> Action-2 label = space/s (measured 7px at 6)
cancelled Action-1 edge -> Action-2 label = 2x (measured 13px at 6)
Same optical-padding rule the whole system runs on: the gap is 0 and the LABEL carries the
space. Do not "fix" this by zeroing the margin. */
/* CLICKABLE INSIDE CLICKABLE, again - the same shape as Media Thumb. Once the card carries
its own buttons it cannot BE an <a>, because a button inside an anchor is invalid HTML and
gives two overlapping targets. The container is an <article>, the link is a stretched
overlay carrying the accessible name, and the actions sit above it. */
.post-card__link {
position: absolute;
inset: 0;
z-index: 1;
}
.post-card__actions { position: relative; z-index: 2; }
/* ONE AFFORDANCE AT A TIME, the same rule Media Thumb states and this file only claimed to.
BOTH STAY LIT deliberately: hovering an action does not stop the card being hovered - the
pointer IS still over the card, and a card that goes flat the moment you reach for its
button reads as though you have left it.
What separates them is the SURFACE, not the presence of a state. A nested control takes
`hover-surface-nested` so it sits ON the card's hover instead of competing with it. Give
it the container's own `hover-surface` and the two paint the same colour, which is a
button that vanishes into the thing it sits on at the exact moment you reach for it.
NOT FILLED. This rule exists so a control with NO background of its own does not paint
the same colour as the plane it sits on. A filled button already carries an opaque
background and an opaque hover (`action-hover`), neither of which can be confused with
the card's tint - it never needed the treatment.
Applying it anyway is a real bug and it shipped: this selector is (0,3,0) and
`.post-btn--filled:hover` is (0,2,0), so it won, replaced the solid fill with a pale
tint and left the label at `on-action` white. A filled primary action turned into
near-invisible white-on-pale at the exact moment you reached for it. The `:not()` is
load-bearing - do not simplify it away. */
.post-card__actions .post-btn:not(.post-btn--filled):hover,
.post-card__actions .post-btn:not(.post-btn--filled).is-hover {
background: var(--hover-surface-nested);
}
/* TYPE COMES FROM THE FIGMA TEXT STYLES, applied as the generated classes in the markup -
`post-text-small` on the eyebrow and meta, `post-text-h4` on the title. Nothing here
re-declares a size or a weight, because a second copy of a text style is a second thing
to keep in sync, and this file lost that argument once already.
eyebrow Text/Small text/sm 500 meta Text/Small text/sm 500
title Heading/H4 text/lg 500
Colour is the card's to set: the two supporting lines are `muted` while the title takes
the full `text`. */
.post-card__eyebrow,
.post-card__meta { color: var(--muted); }
/* OUTLINE — a real surface. Its padding is the space the ghost only borrows. */
.post-card--outline {
padding: var(--card-pad);
border: var(--stroke-m) solid var(--border);
background: var(--surface);
}
/* THE BORDER FOLLOWS THE INK ON HOVER. A hovered card whose edge stays at the resting
`border` colour looks half-lit - the inside responded and the outline did not. One ink
for the whole card. */
.post-card--outline:hover,
.post-card--outline.is-hover {
background: var(--hover-surface);
color: var(--hover-ink);
border-color: var(--hover-ink);
}
/* THE MEDIA FOLLOWS THE CARD'S HOVER, NOT ITS OWN. The card is ONE link, so every part of
it has to respond together. Left to its own `:hover`, the Media Thumb lit up only when the
pointer was over the image: hovering the title changed the text but left the picture at
rest, which reads as two separate targets sitting next to each other rather than one card.
Hovering the image still works - it is inside the card, so the card is hovered too, and
the two stay in step. */
.post-card:hover .post-mediathumb::after,
.post-card.is-hover .post-mediathumb::after { opacity: var(--opacity-20); }
/* The supporting lines are muted AT REST only. Left as `muted` they would win over the
inherited hover ink and stay grey on a card whose title had already changed colour -
the card would hover in two halves. Figma takes all three lines to hover-ink together. */
.post-card:hover .post-card__eyebrow,
.post-card:hover .post-card__meta,
.post-card.is-hover .post-card__eyebrow,
.post-card.is-hover .post-card__meta { color: inherit; }
/* GHOST — no surface at rest, and NO transparent border either. This is the one place
Card deliberately parts company with Button: a ghost button keeps the border so it
matches its outline sibling's box, but a ghost card is supposed to measure ONLY its
content so a grid of them aligns on the media edge. The hover surface is therefore an
absolutely-positioned plate that bleeds OUTSIDE the box and never touches layout. */
.post-card--ghost::before {
content: "";
position: absolute;
inset: calc(-1 * var(--card-pad));
background: var(--hover-surface);
opacity: 0;
pointer-events: none;
z-index: -1; /* behind the content, not over it */
}
.post-card--ghost:hover::before,
.post-card--ghost.is-hover::before { opacity: 1; }
.post-card--ghost:hover,
.post-card--ghost.is-hover { color: var(--hover-ink); }
/* STACKED — media on top. The default, and the only one that survives a narrow column. */
.post-card--stacked { flex-direction: column; }
/* HORIZONTAL — media left, text right. The media is a FIXED rail and the text absorbs
the slack, so the title wraps instead of the image shrinking to a stamp. */
.post-card--horizontal { flex-direction: row; align-items: flex-start; }
.post-card--horizontal > .post-card__media { width: var(--card-media-w, 160px); flex: 0 0 auto; }
.post-card:focus-visible { outline: var(--stroke-m) solid var(--action); outline-offset: var(--stroke-m); }