Components
Media Thumb
An image that carries its own controls and captions in the corners.
Use it where the photograph is the content — a gallery, a venue, a listing hero. What a thing IS reads on the left; what you can DO with it sits on the right.
Watch out. It sits on a photograph this system does not control, so set its light or dark setting deliberately rather than leaving it to the page.
Sub-brandTheme
Liverendered from the shipped recipe
Ratio
portrait default
landscape
square
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.
============================================================================ */
/* ---- media-thumb ---- */
/* THE MEDIA ELEMENT - not a card. An image plane plus the chrome that sits on it,
composed INTO cards rather than being one.
FOUR SLOTS, pinned to the four corners of the plane:
top-left identifying metadata - a Tag Group. Reads first.
top-right a save/pin control. Where a thumb reaches on a phone.
bottom-left title + meta.
bottom-right the primary action - open, play, next.
The rule behind the arrangement: WHAT IT IS reads left, WHAT YOU DO sits right, and
that holds on both rows. Anything else and a person has to re-learn the layout between
the top of the card and the bottom of it.
THE SIZE NEVER CHANGES when a slot or a row is switched off. The plane is an aspect
ratio, not a sum of its contents, so a grid of thumbs with different chrome still lines
up. Verified in Figma too: 320x400 with both rows on, top only, and both off.
EACH ROW OWNS ITS SCRIM, so a row switched off takes its fade with it - a plane with no
bottom chrome must not keep a bottom fade. */
.post-mediathumb {
/* Follows the page theme by default. See the element-theme block below. */
--media-scrim: var(--scrim-media);
--media-ink: var(--text);
/* NESTED CONTROLS FOLLOW THIS ELEMENT'S THEME, NOT THE PAGE'S. This is the one place the
two can disagree: the plane carries its own light/dark, so a control sitting ON it must
take its hover colours from the plane. Left on the page tokens, a brand page resolved
`hover-surface-nested` to brand-deep and put a NAVY disc on a light card - a control
themed for a surface it is not on.
The nested surface is therefore a STEP TOWARD THIS ELEMENT'S OWN INK rather than a
named colour: ink at opacity/20, the same weight as the container's hover wash. On a
light plane that is a soft dark disc, on a dark plane a soft light one, and it can
never fight the plane because it is made of the plane's own ink.
Re-declared as the STANDARD token names, so every nested component picks them up
through the cascade without needing a media-thumb-specific rule of its own. If
color-mix is unsupported the declaration is simply invalid and the page-theme value is
inherited, which is the old behaviour rather than a broken one. */
--hover-ink: var(--media-ink);
--hover-surface-nested: color-mix(in srgb, var(--media-ink) calc(var(--opacity-20) * 100%), transparent);
--hover-surface: var(--hover-surface-nested);
position: relative;
display: flex;
flex-direction: column;
aspect-ratio: var(--media-ratio, 2 / 3);
overflow: hidden;
color: var(--media-ink);
text-decoration: none;
}
/* ELEMENT-LEVEL LIGHT / DARK - the one component that needs it.
Every other component takes its ink from the SECTION theme, because what sits behind it
is a surface this system controls. A media thumb sits on a PHOTOGRAPH, which it does
not control: the same thumb on the same page can need dark ink over a pale sky and light
ink over a night shot. Left to the page theme it would be right half the time.
Unset, it still follows the page - nothing about the existing behaviour changes. Set it
only when you know the image. */
/* A real photograph COVERS; the placeholder plate CONTAINS, because the Figma fill it is
ported from is scaleMode FIT - the mark sits centred at its own size with the neutral
plane around it, and cropping it would show a detail of the mark instead of the mark.
Written as :not() rather than relying on source order, which would decide this silently
between two single-class selectors. */
.post-mediathumb:not(.post-media-placeholder) {
background-size: cover;
background-position: center;
}
.post-mediathumb[data-media="light"] {
--media-scrim: var(--color-neutral-0);
--media-ink: var(--color-neutral-1000);
}
.post-mediathumb[data-media="dark"] {
--media-scrim: var(--color-neutral-1000);
--media-ink: var(--color-neutral-0);
}
/* RATIO — three, and only three. The plane is a frame for a photograph, and a system that
accepts any ratio stops being able to lay anything out: a grid of thumbs only aligns if
the thumbs agree. These are the three a photograph actually arrives in.
portrait 2:3 the default. Vertical media, and what a phone camera produces.
landscape 3:2 the same frame turned - the classic 35mm horizontal.
square 1:1 grids and avatars, where neither dimension may win.
2:3 and 3:2 are deliberately the SAME ratio inverted, not 4:5 and 16:9. One number
describes both orientations, so a portrait and a landscape thumb in one row are
recognisably the same component rather than two unrelated boxes.
Set as a custom property rather than `aspect-ratio` directly, so a consumer with a genuine
one-off can override `--media-ratio` without having to beat a modifier's specificity. */
.post-mediathumb--portrait { --media-ratio: 2 / 3; }
.post-mediathumb--landscape { --media-ratio: 3 / 2; }
.post-mediathumb--square { --media-ratio: 1 / 1; }
/* HOVER IS THE WHOLE PLANE. A single brand plate at opacity/20 across the entire element,
under the chrome. The alternative - recolouring only the row gradients so the photograph
stayed clean - was tried and rejected: the response reads as a property of the CARD, and
lighting only its top and bottom edges makes the middle look like it is not part of the
same target. Brand appears on touch and it covers what you are touching.
z-index 0 keeps it above the media and BELOW the rows, so the scrims and the labels stay
legible through it instead of being tinted twice. */
.post-mediathumb::after {
content: "";
position: absolute;
inset: 0;
z-index: 0;
background: var(--scrim-color);
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease-out;
}
.post-mediathumb:hover::after,
.post-mediathumb.is-hover::after { opacity: var(--opacity-20); }
.post-mediathumb__top,
.post-mediathumb__bottom {
position: relative;
z-index: 1; /* above the media, below the hover wash */
display: flex;
align-items: flex-start;
gap: var(--space-m);
padding: var(--space-m);
}
.post-mediathumb__top::before,
.post-mediathumb__bottom::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
/* 0.5, not 0.85: at 0.85 the dense end read as a painted BAR with a visible edge rather
than a scrim, and the top of the image was gone. The scrim stays NEUTRAL on hover -
the brand comes from the full-plane plate above, not from recolouring this. */
background-image: linear-gradient(var(--media-scrim), transparent);
opacity: var(--opacity-50);
pointer-events: none;
}
.post-mediathumb__bottom::before { background-image: linear-gradient(transparent, var(--media-scrim)); }
/* BOTTOM IS PINNED BY margin-top:auto, and the RIGHT SLOT BY margin-left:auto - NOT by
space-between. Figma's SPACE_BETWEEN distributes only what is VISIBLE, so hiding one
slot lets the other slide into its place; on canvas each row needs an always-present
locked spacer to prevent it. CSS does not: an auto margin resolves against the free
space unconditionally, so -right holds its edge whether or not -left is rendered. Do
NOT add spacer elements here to mirror Figma - the constraint does not exist in CSS. */
.post-mediathumb__bottom { margin-top: auto; align-items: flex-end; }
/* Explicit, not `:last-child`. A last-child heuristic gets the single-slot case backwards:
with only the pin rendered it is BOTH first and last, and a rule keyed on position would
let it fall to the left edge. The slot names its own side, so it holds that side alone. */
.post-mediathumb__slot--right { margin-left: auto; }
.post-mediathumb__body { display: flex; flex-direction: column; gap: var(--space-xs); min-width: 0; }
.post-mediathumb__title { font-size: var(--text-md); font-weight: 500; }
.post-mediathumb__meta { font-size: var(--text-xs); opacity: var(--opacity-85); }
/* CONTROLS ON THE PLANE TAKE THE MEDIA'S OWN INK, not action blue. An icon button
defaults to `color: var(--action)`, which put a blue pin on a photograph - brand colour
appearing at rest, which is the exact thing the scrim model rules out. Over an image the
only ink that stays legible is the one the scrim was built for, so the controls inherit
it. currentColor carries it into the glyph. */
.post-mediathumb .post-iconbtn { color: inherit; }
.post-mediathumb .post-icon { fill: currentColor; }
/* ---- CLICKABLE INSIDE CLICKABLE -------------------------------------------------
The plane is a link AND it carries its own controls. Wrapping the whole thing in an
<a> put a <button> inside an <a>, which is invalid HTML - interactive content cannot
nest - and gave a person two overlapping targets with no way to tell which one a click
would hit.
The fix is that the CONTAINER IS NOT THE LINK. The container is an <article>; the link
is a stretched overlay that covers the plane and carries the accessible name. The
controls sit ABOVE that overlay and take their clicks back. Nothing nests. */
.post-mediathumb__link {
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
}
/* The rows draw the chrome but must not intercept clicks meant for the overlay beneath
them, so they are transparent to the pointer and each real control opts back in. This
is what lets the title area still open the item while the pin stays its own target. */
.post-mediathumb__top,
.post-mediathumb__bottom { pointer-events: none; }
.post-mediathumb__top a, .post-mediathumb__top button,
.post-mediathumb__bottom a, .post-mediathumb__bottom button {
pointer-events: auto;
position: relative;
z-index: 2; /* above the stretched link */
}
/* ONE AFFORDANCE AT A TIME. A nested control gets the NESTED hover surface, never the
container's - `hover-surface-nested` exists for exactly this and Card already documents
it. And while a control is hovered the CONTAINER stops advertising itself, because the
click is going to the control, not to the link. Without this both lit at once and the
card looked like it was about to open while your pointer was on the save button. */
/* BOTH STAY LIT, and that is deliberate. Hovering the pin does not stop the card being
hovered - the pointer IS still over the card, and a card that goes dark the moment you
reach for its save button reads as though you have left it. This is the YouTube video-card
model: the whole tile keeps its hover while the inner control shows its own.
What separates them is the SURFACE, not the presence of a state. The nested control takes
`hover-surface-nested` - the token exists for exactly this, and Card already documents it -
so it sits ON the container's hover rather than competing with it. */
.post-mediathumb .post-iconbtn:hover,
.post-mediathumb .post-iconbtn.is-hover { background: var(--hover-surface-nested); }
.post-mediathumb:focus-visible { outline: var(--stroke-m) solid var(--action); outline-offset: var(--stroke-m); }