Using contain to Scope Repaints

Part of Paint Invalidation & Repaint Boundaries in Performance Budgeting & GPU Architecture.

The problem

A live-updating component — a comment count, a status pill, a timestamp that ticks — changes one small piece of text, and paint flashing lights up a region ten times its size. Nothing about the change is expensive; the expense is entirely in how far the browser has to look to be sure nothing else changed.

Containment is the tool that answers that question in advance. Used well it turns a page-wide invalidation into a component-sized one. Used indiscriminately it clips dropdowns, collapses boxes and breaks positioning in ways that are hard to trace back to a one-line declaration.

Root cause: the browser cannot assume independence

Without containment, a change inside an element can affect anything: a taller child can push siblings down, an overflowing shadow can paint outside the parent, a counter can renumber a list elsewhere in the document. The browser has no way to know it will not, so the invalidation walks up the tree and the dirty rectangle grows.

Each containment keyword is a promise that removes one of those possibilities.

layout promises that the element’s internal geometry cannot affect anything outside it — no child can change the position of a sibling of the container. paint promises that no descendant paints outside the container’s box, which lets the browser clip and, crucially, stop expanding the dirty rectangle at that boundary. style promises that counters and quotes do not escape. size promises that the box’s dimensions do not depend on its contents at all.

The shorthands compose these: content is layout paint style, and strict is all four. The right choice is the weakest one that still makes the promise you need, because every promise is enforced — and enforcement is what breaks layouts.

What each keyword promises and what it costs youThe cost column is the behaviour change you have to design around, not a performance cost.What each keyword promises and what it costs youPromiseWhat it changesSafe forcontain:layoutInternal geometry staysinsideBecomes a containingblockMost componentscontain:paintDescendants cannot paintoutsideClips overflowCards without popoutscontain:styleCounters and quotes stayinsideCounter scope changesAlmost anythingcontain:sizeSize ignores contentsCollapses withoutexplicit sizeFixed-size widgets onlycontain:contentlayout + paint + styleClips overflowThe usual choicecontain:strictAll fourClips and collapsesSelf-sizing widgets
The cost column is the behaviour change you have to design around, not a performance cost.

Step-by-step resolution

Applying containment to a real componentMeasure, choose the weakest keyword, check the overflow cases, measure again.Applying containment to a real component1Turn on Rendering > Paint flashing and trigger the update that repaints.Note how far past the component the flash extends2Add contain: content to the component's outermost box.The weakest shorthand that scopes paint3Look for anything designed to escape the box: menus, tooltips, focusrings.Those need a different home or no containment4Re-run paint flashing and confirm the flash is now bounded by thecomponent.If not, an ancestor effect is expanding the rectangle5Only add size containment if you are supplying the dimensions yourself.
Measure, choose the weakest keyword, check the overflow cases, measure again.

Production code pattern

/* Intent: a live-updating activity card whose timestamp ticks every second.
   Containment keeps each tick inside the card's own box. */
.activity {
  /* layout + paint + style: the card cannot affect anything outside itself,
     and nothing inside it paints beyond its border box. */
  contain: content;

  /* Paint containment clips, so the focus ring is drawn INSIDE the box. */
  position: relative;
  isolation: isolate;
}

.activity:focus-visible {
  /* An inset ring, because an outset outline would be clipped away. */
  outline: 2px solid var(--color-primary-600);
  outline-offset: -2px;
}

.activity__timestamp {
  /* The only thing that changes per tick. Its dirty rectangle can now reach
     no further than .activity's border box. */
  font-variant-numeric: tabular-nums;   /* no reflow as digits change width */
}

.activity__menu {
  /* A popout menu CANNOT live inside a paint-contained box: it would be
     clipped. Render it in the top layer instead. */
  position: fixed;
}

@media (prefers-reduced-motion: reduce) {
  .activity { transition: none; }
}

Rendering Impact: paint, scoped. The tick still repaints — text always does — but the dirty rectangle is bounded by the card rather than by whatever ancestor the browser would otherwise have had to consider.

Two details in that block are the ones that bite in practice. tabular-nums prevents the timestamp changing width as digits change, which would otherwise cause a layout inside the card on every tick — containment stops that layout escaping, but it is cheaper still not to trigger it. And the inset outline exists because paint containment clips: an ordinary outline-offset: 2px focus ring on a contained element is invisible, which is an accessibility regression introduced by a performance change.

Repainted area for one timestamp tickThe text change is identical in all three cases; only the boundary differs.Repainted area for one timestamp tickNo containment148 k px squaredcontain: layout only96 k px squaredcontain: content11 k px squaredcontain: content + tabular-nums4 k px squared
The text change is identical in all three cases; only the boundary differs.

Verification checklist

Constraints and trade-offs

  • Paint containment clips, so any escaping UI must be repositioned, portalled or left uncontained.
  • Layout containment makes the element a containing block for position: fixed descendants as well as absolute ones, which can move a modal you did not expect to move.
  • Style containment changes counter scope, which quietly renumbers ordered lists that relied on document-wide counters.
  • Containment on hundreds of elements has its own bookkeeping cost in the style pass; apply it where measurement shows a benefit.
  • A compositor layer is also a repaint boundary, so an element that is already promoted gains nothing from paint containment.

Frequently asked questions

What is the difference between contain: content and contain: strict?

content is layout, paint and style containment; strict adds size containment. The difference matters: size containment makes the element’s dimensions independent of its contents, so a box with no explicit height collapses. Use content unless you are deliberately sizing the box yourself.

Does containment improve performance on its own?

Only if something was actually spreading. Containment is a promise that lets the browser skip work it would otherwise have to do; on a component that never repaints and never affects its siblings, it changes nothing. Measure before and after rather than applying it broadly.

Can containment break my layout?

Yes, deliberately. Paint containment clips overflow, layout containment makes the element a containing block for absolutely positioned descendants, and size containment ignores content when sizing. Each of those is a real behaviour change, which is why the weakest sufficient keyword is the right default.


Containment and the accessibility tree

Containment changes rendering, not semantics — with one exception that catches people out.

contain: layout, paint and style leave the accessibility tree untouched. A contained element and its descendants are exposed exactly as before, focus order is unchanged, and a screen reader sees no difference. That is the whole reason containment is a safe optimisation in a way that hiding content is not.

The exception is what paint containment does to visible focus indication. An outline drawn outside the border box — the default for outline-offset values above zero, and the browser’s own focus ring in several engines — is clipped away, silently. The element is still focusable and still announced; it simply has no visible indicator, which is a WCAG failure introduced by a performance change and invisible to anyone testing with a mouse.

Two habits prevent it. Use a negative outline-offset on paint-contained elements so the ring is drawn inside the box, as in the pattern above. And include a keyboard pass in the verification for any containment change: tab through the contained region and confirm every stop is visibly indicated, following the audit in focus and state motion accessibility.

The same clipping applies to any decorative overflow the design relies on — a badge that pokes out of a card corner, a shadow that extends past the edge — which is why the check for escaping content comes before the containment is committed rather than after.