COMPONENT
Frame
A real image, held at a ratio. It is the counterpart to the placeholder and deliberately the same box, so a layout you reviewed against stand-ins does not move when the photographs arrive.
One source, two ratios
The same 16px square file at 16:9 and 4:5. Only --ratio changed. The source is a flat swatch of the placeholder's tint, so what you are looking at is the box.
--ratio changed.
<!-- The source is a 16px square of the placeholder's own tint, inlined so the
book ships no binaries. Any <img> goes here; this one is flat so the box,
not the picture, is what you are looking at. -->
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr)); gap:20px; width:100%">
<figure style="margin:0; display:flex; flex-direction:column; gap:14px">
<div class="frame" style="--ratio:16/9">
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAFklEQVR42mMQV9ElCTGMahjVMHw1AAA2YWgBpC4dPQAAAABJRU5ErkJggg==" alt="">
</div>
<figcaption class="placeholder-caption">
<span class="placeholder-caption-index">FIG 01</span>16:9. A 16px square source, filling a wide box.
</figcaption>
</figure>
<figure style="margin:0; display:flex; flex-direction:column; gap:14px">
<div class="frame" style="--ratio:4/5">
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAFklEQVR42mMQV9ElCTGMahjVMHw1AAA2YWgBpC4dPQAAAABJRU5ErkJggg==" alt="">
</div>
<figcaption class="placeholder-caption">
<span class="placeholder-caption-index">FIG 02</span>The same file at 4:5. Only <code>--ratio</code> changed.
</figcaption>
</figure>
</div>Before and after the asset arrives
Placeholder and frame, side by side at one ratio. Same hairline, same box, same footprint β which is the claim this page exists to make.
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr)); gap:20px; width:100%">
<figure style="margin:0; display:flex; flex-direction:column; gap:14px">
<div class="placeholder" style="--ratio:16/9">
<span class="placeholder-kicker">PLACEHOLDER</span>
<span class="placeholder-note">Editor, mid-stroke β 16:9</span>
</div>
<figcaption class="placeholder-caption">
<span class="placeholder-caption-index">BEFORE</span>The asset has not arrived. The box is designed, not missing.
</figcaption>
</figure>
<figure style="margin:0; display:flex; flex-direction:column; gap:14px">
<div class="frame" style="--ratio:16/9">
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAFklEQVR42mMQV9ElCTGMahjVMHw1AAA2YWgBpC4dPQAAAABJRU5ErkJggg==" alt="">
</div>
<figcaption class="placeholder-caption">
<span class="placeholder-caption-index">AFTER</span>The asset arrived. Same border, same ratio, same footprint β nothing around it moved.
</figcaption>
</figure>
</div>A drawing is not a photograph
One wide diagram in a square box, twice. The default crops to fill and takes both ends with it; frame-contain shows the whole thing and the frame's tint becomes the mat around it.
frame-contain. Same file, same box, whole picture, and the tint is now the mat.
<!-- One wide drawing, twice, in a square box. The source is inlined so the book
ships no binaries: a 3:1 diagram whose two ends are the whole point.
BASE64 RATHER THAN A PERCENT-ENCODED SVG, and that is not a preference. An
SVG written inline into src="β¦" carries double quotes of its own, and the
first one closes the attribute β the picture silently does not load and the
box looks empty but correct. -->
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr)); gap:20px; width:100%">
<figure style="margin:0; display:flex; flex-direction:column; gap:14px">
<div class="frame" style="--ratio:1/1">
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjAgNDAiPjxnIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzZiOGVhMyIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiPjxyZWN0IHg9IjIuNSIgeT0iMi41IiB3aWR0aD0iMTE1IiBoZWlnaHQ9IjM1Ii8+PGNpcmNsZSBjeD0iMjAiIGN5PSIyMCIgcj0iOSIvPjxjaXJjbGUgY3g9IjEwMCIgY3k9IjIwIiByPSI5Ii8+PHBhdGggZD0iTTMxIDIwSDg5Ii8+PHBhdGggZD0iTTYwIDh2MjQiLz48L2c+PC9zdmc+" alt="">
</div>
<figcaption class="placeholder-caption">
<span class="placeholder-caption-index">FIG 01</span>The default. It crops to fill, and both ends of the drawing are gone.
</figcaption>
</figure>
<figure style="margin:0; display:flex; flex-direction:column; gap:14px">
<div class="frame frame-contain" style="--ratio:1/1">
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjAgNDAiPjxnIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzZiOGVhMyIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiPjxyZWN0IHg9IjIuNSIgeT0iMi41IiB3aWR0aD0iMTE1IiBoZWlnaHQ9IjM1Ii8+PGNpcmNsZSBjeD0iMjAiIGN5PSIyMCIgcj0iOSIvPjxjaXJjbGUgY3g9IjEwMCIgY3k9IjIwIiByPSI5Ii8+PHBhdGggZD0iTTMxIDIwSDg5Ii8+PHBhdGggZD0iTTYwIDh2MjQiLz48L2c+PC9zdmc+" alt="">
</div>
<figcaption class="placeholder-caption">
<span class="placeholder-caption-index">FIG 02</span><code>frame-contain</code>. Same file, same box, whole picture, and the tint is now the mat.
</figcaption>
</figure>
</div>A picture that is a link
Press either one. The whole box is the target β a reader who wants the project aims at the picture, not at the title beside it.
<!-- A picture that is a link to whatever it is a picture of. The anchor covers
the frame rather than wrapping it, and carries the name β a link whose
content is nothing announces as nothing. -->
<div class="frame" style="--ratio:16/9; width:min(100%,260px)">
<img src="/gallery/rico-15-thumb.webp"
alt="A dog in profile against a deep blue sky, sitting in tall meadow grass." />
<a class="frame-link" href="/primitives/placeholder/" aria-label="Rico in the meadow"></a>
</div>
<!-- Over a clip, the link goes AFTER the video and BEFORE the control. `clip`
makes the frame a stacking context, so document order alone decides what is
on top β put the link last and it swallows the pause button.
There is no `controls` attribute here, and that is the trade: with no
script the browser's own play button would sit under the link. Media that
navigates does not also play. -->
<div class="frame clip" style="--ratio:16/9; width:min(100%,260px)" x-ds-clip
data-label-play="Play the clip" data-label-pause="Pause the clip">
<video muted loop playsinline preload="none" poster="/gallery/rico-17-thumb.avif">
<source src="/gallery/rico-17-thumb.webm" type="video/webm" />
<source src="/gallery/rico-17-thumb.mp4" type="video/mp4" />
</video>
<a class="frame-link" href="/components/clip/" aria-label="A clip of Rico"></a>
<button class="clip-toggle" type="button" hidden></button>
</div>Reference
Read from components/frame.css when this page was built, so it cannot fall behind the stylesheet.
.frame.frame-bare.frame-contain.frame-link--ds-focus-ring-inset--ds-focus-width--ds-hairline--ds-radius-frame--ds-rule--ds-tint-coolNotes
A photograph is a window onto something larger, so cutting the overflow is honest: there is more scene out there either way, and a row of differently-sized sources ends up looking deliberate.
A drawing has no outside. A diagram, a logo, an export from a modeller β the edges of the artwork are the artwork, and cropping removes the picture rather than reframing it. That is two kinds of picture, not one kind with a bad crop, which is why the answer is frame-contain and not a better file.
Normally --ds-tint-cool under a frame is what shows between layout and decode, so a grid of frames does not flicker as it loads. A contained picture rarely fills its ratio, so that tint is what a reader sees around it for as long as the page is open.
That is a feature rather than a side effect: artwork drawn on transparency gets a surface to sit on, in whichever theme is nearest it. A caller who wants a different mat moves --ds-tint-cool on the subtree β or puts a data-theme on it β rather than painting a colour at the call site.
An <a> may not contain interactive content, and a clip's pause button is exactly that β so wrapping the frame produces invalid markup and a control nobody can press. The anchor is a sibling of the media, stretched over the box.
It goes after the media and before any control. clip makes the frame a stacking context, so what sits on top inside it is decided by document order alone. Put the link last and it swallows the pause button β silently, and only for the people who go looking for it.
It paints nothing. A photograph does not need an underline, and the title beside it is usually a link to the same place; the focus ring is inset, because the frame's overflow: hidden would clip an outset one into stubs. The name is the caller's β a link whose content is nothing announces as nothing. A check refused that until the interaction pass was removed on 21 August 2026; nothing catches it now.
If the picture is the only way to the destination, name the anchor. The specimen above does, because there is nothing else to press.
On a card there usually is: the title beside it links to the same place. A named overlay then announces that destination twice and puts a second stop in the tab order on the way to the next card. Mark it aria-hidden="true" tabindex="-1" and it becomes what it really is β a bigger target for a pointer, and nothing at all to anybody else. That is the same pair of attributes a form's honeypot carries, and for the same reason: removed from the accessibility tree and from the tab order is the only honest way to say "not a target".
With no script a clip keeps its controls so it can still be played, and an overlay link sits on top of them. There is no arrangement where both work, so it is a choice the caller makes: media that navigates drops controls; media that plays takes no link.
In practice that decides itself. On a card in a list the reader wants the project, so the picture navigates. On the project's own page there is nothing to link to β so it keeps its controls, and no-script playback survives exactly where it was going to be used.
object-fit: cover fills the ratio and cuts the overflow, which is what lets a row of differently-sized sources read as one deliberate set. An image whose subject sits at the very edge will lose that subject β a content decision, answered with a better crop in the file rather than a rule here. The specimens above use a flat swatch and so cannot show you this happening; it is the one behaviour on this page you have to take on trust, and the reason the note says so rather than staying quiet about it.
--ratio takes an aspect-ratio expression β 16/9, 4/5, 21/9 β and defaults to 16:9. A consuming site names its ratios once, as its own classes, rather than spelling the fraction at every call site.
There is one good reason to reach past the ratio: a wall where every box in a row must share one height, so each frame fills the cell it is given. The obvious spelling is block-size: 100% and it has a sharp edge.
aspect-ratio sizes whichever axis is auto. Give it a definite height and leave the width alone, and it computes the width. Measured on a consuming site: a frame filling a 227px cell with --ratio: 21/9 came out 530px wide inside a 365px column, at every viewport - 530 is 227 x 21/9, and a width that never changes with the window is the tell. It overflowed the page and swallowed its neighbour's gutter, which reads as a broken gap rather than a broken width.
So a frame that takes its height from its container states both axes: --ratio: auto, inline-size: 100%, block-size: 100%. Then the ratio cannot decide either one. Stating only the height is the version that looks right in the row you wrote it in.
A responsive image wraps its img in picture, which is an inline element by default β so the image would size against a line box and the ratio would quietly stop holding. Both are addressed, which is why swapping a plain image for a source set changes nothing about the layout.
It is what shows between layout and decode. Without it the box flashes the page background, and a grid of frames appears to flicker as the page loads.