Layout
Measured scrim band
A two-phase gradient that ramps and then holds flat under a text region hard-capped to exactly that held band, so the worst-case contrast is an enumerable number rather than whatever alpha a line of text happened to land on.
- layout
- css-only
- gradients
- contrast
- cards
<div class="msb-row">
<div class="msb-tile">
<div class="msb-art msb-art-light" aria-hidden="true"></div>
<div class="msb-scrim" aria-hidden="true"></div>
<div class="msb-text">
<span class="msb-title">Configure alerting rules and notification channels</span>
<span class="msb-meta">Updated 12 hours ago</span>
</div>
</div>
<div class="msb-tile">
<div class="msb-art msb-art-dark" aria-hidden="true"></div>
<div class="msb-scrim" aria-hidden="true"></div>
<div class="msb-text">
<span class="msb-title">Audit log view</span>
<span class="msb-meta">16K entries, last 7 days</span>
</div>
</div>
</div>Colors come from shared theme tokens — --surface, --ink, --border, --accent and friends — so this CSS carries no palette
of its own. Use Runnable file to copy the tokens along with it.
.msb-row { display: flex; gap: 1.5rem; flex-wrap: wrap; }
.msb-tile {
/* The single source of truth: the gradient's flat point and the text
box's height are both derived from this one value, so the CSS band
cannot quietly outgrow the region a contrast check measured. */
--msb-hold: 50%;
position: relative;
overflow: hidden;
width: 260px;
height: 200px;
border-radius: 8px;
border: 1px solid var(--border);
flex: 0 0 auto;
}
.msb-art {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
}
.msb-art-light { background-image: linear-gradient(160deg, #ffffff 0%, #f2f4f7 60%, #e6eaef 100%); }
.msb-art-dark { background-image: linear-gradient(160deg, #3a6ea5 0%, #1d3557 60%, #0d1b2a 100%); }
/* Ramps from a light scrim at the top to a dark one at --msb-hold, then
holds flat for the rest of the tile — CSS gradients continue the final
stop's color past its own position with no further declaration needed.
Every pixel under the text sits on the exact same alpha. */
.msb-scrim {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.8) var(--msb-hold));
}
.msb-text {
position: absolute;
inset: auto 0 0 0;
max-height: calc(100% - var(--msb-hold));
overflow: hidden;
padding: 0.75rem 0.9rem;
display: flex;
flex-direction: column;
gap: 2px;
}
/* Fixed white/grey ink, deliberately not a theme token: this text sits on
the scrim and the artwork, never on the page ground, so it is solved
against the scrim's own worst case rather than against the theme. */
.msb-title { color: #ffffff; font-weight: 600; font-size: 1.0625rem; line-height: 1.3; }
.msb-meta { color: #e3e3e3; font-size: 0.875rem; }Paste this into an agent to rebuild the pattern from scratch.
Build a cover tile that prints its title directly on top of the record's artwork, using a scrim that ramps from transparent and then holds at a flat, constant alpha for the entire region the text is allowed to occupy.
Reach for this on compact tiles and cards where the title has to sit on the artwork itself rather than in a caption strip below it — media grids, file browsers, any card-per-record layout dense enough that a separate caption band would cost too much vertical space. It is the wrong choice for a hero-scale header with room to spare below the artwork; there, giving text its own solid-color band beneath the image is simpler and does not need this machinery at all.
A plain top-to-bottom gradient scrim is the tempting first draft and the wrong one: with a straight ramp, each line of text lands on whatever alpha the gradient happens to reach at that pixel row, which depends on how many lines the title wraps to. A one-line title and a two-line title sit on different contrast values, and neither value is one a test can assert against, because it was never a fixed number to begin with.
The fix is a two-phase gradient. Ramp normally for the first portion, then hold flat: specify only two color stops, a light one at the top and a dark one at the fixed hold point, and let the gradient's own behavior carry the final color forward unchanged for the rest of the box — a CSS gradient continues its last explicit stop's color past that stop's position with no further declaration. Everything below the hold point is now one exact, constant alpha, regardless of how many lines of text end up there.
Cap the text region to precisely that held band, and derive both the gradient's hold point and the text box's height from the same single value — a custom property read by both the background declaration and a "max-height: calc(100% - value)" on the text container. Deriving them from one number is what keeps the promise: if the hold point is ever changed, the text box's ceiling moves with it automatically, so the band that was measured for contrast is the band that can never be exceeded.
With the alpha now fixed and enumerable, the worst case is a single arithmetic fact instead of a range: composite the scrim's darkest stop over the lightest artwork the tile is expected to show, and check the ink against the result. A dark scrim held around 0.8 alpha over even a near-white image composites down to a mid-dark grey, comfortably legible with plain white text — a number a build gate can assert, not a guess.
Refuse text-shadow as a substitute for this, even though it is visually similar. A scrim's alpha is a number: it can be extracted, composited against a known worst-case background, and checked in a test. A shadow's effect on effective contrast is not a number anything can cleanly compute — it depends on the exact pixels underneath, which is exactly the property this pattern exists to eliminate. Ship the flat-held scrim, and use a fixed white-and-grey ink pair rather than a theme token for the text, since the text is solved against the scrim's own worst case, not against the page's light or dark ground.