zudo-css-wisdom
GitHub repository

Type to search...

to open search from anywhere

Letterbox Stage

Fit a fixed-aspect stage inside a bounded box with bars on the correct axis — max-height cannot do it; a size container can.

The Problem

A 16:9 slide, a video player, a game canvas — a fixed-aspect stage must fit inside an arbitrary bounded box, shrinking to whichever axis runs out first and leaving bars on the other axis. The obvious pattern is width-driven and does not do this:

.frame {
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
}

.stage {
  aspect-ratio: 16 / 9;
  width: 100%;
  max-width: 100%;
  max-height: 100%;
}

When the frame is height-constrained — wider than 16:9 — max-height: 100% does not re-derive the width from the aspect ratio. aspect-ratio transfers size from the determining axis to the other axis, and a min/max constraint only becomes the determining axis when the size in that axis is auto. Here width: 100% is definite, so max-height acts as a plain clamp: the stage keeps its full width, its height is cut to the frame height, and the 16:9 ratio breaks. Content inside distorts, overflows, or gets clipped — it never letterboxes with side bars.

This pattern shipped in a real project and passed every unit test. At narrow widths the stage is width-driven and looks correct; the failure only appears in a real, wide, height-bounded viewport.

The Solution

Make the frame a size container and compute the stage width against both axes:

.frame--contain {
  container-type: size;
}

@container (min-width: 0) {
  .frame--contain .stage {
    width: min(100cqw, calc(100cqh * 16 / 9));
  }
}

min() picks the smaller of two candidate widths: 100cqw (fill the frame's inline axis) and calc(100cqh * 16 / 9) (the width a full-height 16:9 stage would have). Whichever axis runs out first wins, and aspect-ratio derives the height from the resulting width. This is the computation max-height alone can never perform: both axes participate in determining the width.

The --contain modifier is deliberate. container-type: size is not free — apply it only where the frame has bounded height (see the hazard section below).

Code Examples

The Width-Driven Trap

The demo frame uses a fixed 220px height because the preview iframe itself is an unbounded-height context. The circle is drawn with percentage width and height chosen so it is round only when the stage is truly 16:9.

Switch the viewport buttons: at Mobile the frame is narrower than 16:9, the stage is width-driven, and everything looks correct — this is why narrow-viewport tests pass. At Tablet and Full the frame is wider than 16:9: the stage should shrink to a 391px-wide letterboxed box, but instead it keeps full width, squashes to the frame height, and the circle becomes an ellipse.

Broken: width-driven stage in a height-bounded frame

The Size-Container Fix

Same markup, same base .stage rules — the frame adds the frame--contain modifier and the container-scoped width rule. Now the viewport buttons show the bars flipping axis: at Mobile the width axis runs out first and the bars sit above and below; at Tablet and Full the height axis runs out first and the bars sit on the sides. The circle stays round everywhere.

Fixed: size container + min() letterbox

Two details of the rule:

  • place-items: center on the frame centers the stage, so the bars split evenly on whichever axis has slack.

  • The @container (min-width: 0) gate is always true when an ancestor container exists — and only then. Without a container ancestor, cqw/cqh fall back to small viewport units and would size the stage against the browser window instead of the frame. Gating the declaration guarantees the min() width only ever applies inside an actual container context; everywhere else the stage cleanly stays width-driven.

Scope the Containment: the Opt-In Class

container-type: size makes the element's size independent of its contents. On a wrapper with height: auto, that collapses the wrapper to zero height — the content still renders, overflowing a zero-height box.

Hazard: container-type: size collapses auto-height wrappers

This is why the fix uses an opt-in --contain class instead of containment on every frame. An unconditional version of this pattern shipped once and broke an unrelated iframe-height-reporting context: the auto-height wrapper collapsed, and the embedded page reported a height of zero. Any mechanism that measures a wrapper to derive layout — iframe height reporting, scroll calculations, virtualized lists — reads zero from a size-contained auto-height element. Containment is not free; scope it to frames with bounded height.

Width-Driven Is Correct When Height Is Unbounded

An iframe embed page, a docs preview pane, or any wrapper whose height grows with its content has no height bound. There, height: 100% on the frame resolves against an auto-height parent and max-height: 100% resolves to none — the block axis offers nothing to letterbox against. The stage renders width-driven: full width, height derived from the ratio.

This is expected behavior, not a bug. Letterboxing requires a bound on both axes. Do not "fix" it with container-type: size — on an auto-height wrapper, size containment collapses the wrapper instead, as shown above. In unbounded-height contexts, width: 100%; aspect-ratio: 16 / 9 is the correct final answer.

The transform-scale Alternative

Presentation frameworks in the reveal.js and Slidev lineage solve the same problem differently: the stage keeps a fixed design size in pixels, and JavaScript scales it to fit.

.frame {
  display: grid;
  place-items: center;
  overflow: hidden;
}

.stage-scaled {
  width: 960px;
  height: 540px;
  transform-origin: center;
}
const frame = document.querySelector('.frame');
const stage = document.querySelector('.stage-scaled');
const DESIGN_W = 960;
const DESIGN_H = 540;

const observer = new ResizeObserver((entries) => {
  const { width, height } = entries[0].contentRect;
  const scale = Math.min(width / DESIGN_W, height / DESIGN_H);
  stage.style.transform = `scale(${scale})`;
});
observer.observe(frame);

Both approaches produce the same letterboxed geometry. The trade-offs:

ConcernPure CSS letterboxtransform: scale()
First paintCorrect before any JavaScript runsWrong size until the ResizeObserver fires
Text renderingLaid out at real size — always crispWebKit rasterizes text at the design size; non-integer scale factors blur it
PrintPrints as laid out — nothing to undoThe transform must be reset in print styles
Scale factor for embeddersNot exposed — the sizing lives inside CSSAn explicit number, reusable for thumbnails and zoom UI
Coordinate systemContent reflows; positions are stage-relative (%, cqi)Content keeps design-pixel coordinates; the factor maps them 1:1

Default to the pure-CSS letterbox. Reach for transform-scale when the embedder needs the literal scale factor, or when content is authored against pixel-exact design-size coordinates that must not reflow.

Interior Fluidity with cqi Units

A letterboxed stage changes size with its frame, so content inside it should scale with the stage, not the viewport. Make the stage an inline-size container and size its content in cqi units — see Container Queries.

Interior fluidity: cqi units scale content with the stage

The two container types nest cleanly: the frame is a size container (both axes, for the letterbox math), and the stage is an inline-size container (for its own content). Container query units in a rule resolve against the nearest ancestor container of the matched element, so cqw/cqh in the stage's width rule read the frame, and cqi in the content rules reads the stage. The gap and padding live on an inner element for the same reason: an element's own container-type never affects its own declarations, so cqi values placed directly on the stage would resolve against the frame instead. The inner element also carries box-sizing: border-box, keeping its padding inside the aspect-ratio-derived box.

When the Frame Is a Flex or Grid Child

The bounded frame usually gets its bounds from a flex or grid parent — a flex: 1 panel next to a toolbar, a 1fr grid track. Flex and grid children default to min-width: auto / min-height: auto, which stops them from shrinking below their content size, so the frame may refuse to track the available space as the window shrinks:

.stage-panel {
  flex: 1;
  min-width: 0;
  min-height: 0;
}

container-type: size on the frame itself already zeroes its intrinsic size contribution, but the resets remain necessary on any non-contained wrappers between the flex or grid parent and the frame. See Flexbox Patterns for the general rule.

Quick Reference

ScenarioTechnique
Fixed-aspect stage in a height-bounded framecontainer-type: size on the frame + width: min(100cqw, calc(100cqh * 16 / 9))
Frame height is auto / unboundedStay width-driven: width: 100%; aspect-ratio: 16 / 9 — no size containment
Embedder needs the numeric scale factorFixed-px stage + transform: scale() from a ResizeObserver
Stage content must scale with the stagecontainer-type: inline-size on the stage + cqi units
Frame is a flex or grid childmin-width: 0 / min-height: 0 on the frame's wrappers
Media inside a box (img, video)object-fit: contain — see aspect-ratio

Common AI Mistakes

  • Expecting max-height: 100% to letterbox. Min/max constraints transfer through aspect-ratio only when the size in that axis is auto. With width: 100% set, max-height just clamps the height and breaks the ratio. This failure passes narrow-viewport tests and appears only in wide, height-bounded layouts.

  • Applying container-type: size to every wrapper. On auto-height wrappers it collapses the element to zero height, and anything that measures the wrapper — iframe height reporting, scroll math — reads zero. Scope containment with an opt-in class.

  • Using 100vw/100vh instead of 100cqw/100cqh. Viewport units size the stage against the browser window, not the frame. The letterbox math must read the frame's box.

  • "Fixing" width-driven behavior in unbounded-height embeds. With no height bound there is nothing to letterbox against; width-driven rendering is the correct result, and adding size containment there collapses the wrapper.

  • Forgetting min-width: 0 / min-height: 0 on flex or grid wrappers around the frame. The default auto minimum stops the frame from shrinking, so the stage never sees the reduced space.

  • Assuming transform: scale() renders like real-size layout. WebKit rasterizes text at the design size and blurs it at non-integer scale factors, and the transform leaks into print output.

When to Use

Use the size-container letterbox when

The frame has bounded width and height and the stage must fit with bars on the correct axis. This is the default: correct at first paint with no JavaScript, crisp text, and clean print output.

Use transform-scale when

The embedder needs the literal scale factor (thumbnail strips, zoom controls), or the stage content is authored against pixel-exact design-size coordinates that must not reflow — the slide-tool use case.

Stay width-driven when

The context has no height bound — iframe embeds, preview panes, normal document flow. width: 100%; aspect-ratio: 16 / 9 is complete there; letterboxing does not apply.

References

Revision History

CreatedUpdated