POST Design System Using the system
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-brand
Theme
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); }