CSS content-visibility: render only what is on screen

content-visibility: auto tells the browser it may skip layout and paint for sections that are far from the screen. On a long page that makes the first frame much cheaper, with one size hint to keep the scrollbar honest.

content-visibility is a CSS property that lets the browser skip the rendering work for an element's contents. With content-visibility: auto, sections far from the screen are not laid out or painted until the user scrolls near them. Two lines on each repeated block are enough:

.section {
  content-visibility: auto;
  contain-intrinsic-size: auto 500px;
}

Scroll the frame below. The line at the top lists the sections the browser is rendering right now. The rest are skipped.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>content-visibility: auto</title>
<style>
  body { margin: 0; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  header {
    position: sticky; top: 0; z-index: 1; padding: 10px 14px;
    background: #fff; border-bottom: 1px solid #e1e4ea; font-size: 13px;
  }
  header label { display: block; margin-bottom: 4px; }
  #out { color: #0f5132; font-weight: 600; }
  main { padding: 12px 14px; }
  .sec {
    content-visibility: auto;  /* skip layout and paint while off-screen */
    margin-bottom: 12px; padding: 12px 14px; border-radius: 10px;
    background: #fff; box-shadow: 0 1px 4px rgba(0, 0, 0, .08);
  }
  .cis .sec { contain-intrinsic-size: auto 120px; }  /* placeholder height */
  .sec h2 { margin: 0 0 6px; font-size: 16px; }
  .sec p { margin: 0; font-size: 14px; line-height: 1.5; color: #4b5563; }
  .bar { height: 40px; margin-top: 10px; border-radius: 6px; background: linear-gradient(90deg, #cfe8ff, #d4f5dc); }
</style>
</head>
<body>
<header>
  <label><input type="checkbox" id="cis" checked> contain-intrinsic-size: auto 120px</label>
  <div id="out">Scroll this frame.</div>
</header>
<main id="list"></main>

<script>
  const list = document.getElementById('list');
  const out = document.getElementById('out');
  const rendered = new Set();

  function show() {
    const ids = [...rendered].sort((a, b) => a - b);
    out.textContent = 'Rendered: ' + (ids.length ? ids.join(', ') : 'none') +
      ' (' + ids.length + ' of 40) · page height ' + document.documentElement.scrollHeight + 'px';
  }

  function build() {
    rendered.clear();
    list.className = document.getElementById('cis').checked ? 'cis' : '';
    list.innerHTML = '';
    for (let i = 1; i <= 40; i++) {
      const s = document.createElement('section');
      s.className = 'sec';
      s.innerHTML = '<h2>Section ' + i + '</h2><p>This text is laid out and painted only ' +
        'when the section is near the screen.</p><div class="bar"></div>';
      // fires when the browser starts or stops skipping this section
      s.addEventListener('contentvisibilityautostatechange', (e) => {
        e.skipped ? rendered.delete(i) : rendered.add(i);
        show();
      });
      list.append(s);
    }
    scrollTo(0, 0);
    requestAnimationFrame(() => requestAnimationFrame(show));
  }

  document.getElementById('cis').addEventListener('change', build);
  addEventListener('scroll', show);
  build();
</script>
</body>
</html>
40 sections with content-visibility: auto. Untick the box to drop contain-intrinsic-size and watch the page height.

What the browser skips

Normally the browser works out the position and look of every element on the page before it shows the first frame. That includes sections a reader may never scroll to.

Left: every section gets layout and paint. Right: only the sections near the screen do.
Left: every section gets layout and paint. Right: only the sections near the screen do.

With auto, a section that is far from the screen keeps its box, but its children are skipped: no style calculation, no layout, no paint. When it comes close to the screen, the browser renders it before it scrolls into view.

The demo listens for the contentvisibilityautostatechange event, which fires when a section starts or stops being skipped. Its skipped property says which. You can use the same event to pause work, such as a canvas animation, while a section is off-screen.

Measured on a long page

The next demo builds 1,500 posts, each with a heading, a paragraph and a grid of 12 tags. Each button rebuilds the page and times the gap from inserting the HTML to the first painted frame.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Long page timing</title>
<style>
  body { margin: 0; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; font-size: 14px; }
  header {
    position: sticky; top: 0; z-index: 1; padding: 10px 14px;
    background: #fff; border-bottom: 1px solid #e1e4ea;
  }
  .btns { display: flex; flex-wrap: wrap; gap: 6px; }
  button { font: inherit; font-size: 13px; padding: 6px 10px; border-radius: 8px; border: 1px solid #c9cdd4; background: #fff; cursor: pointer; }
  table { width: 100%; margin-top: 8px; border-collapse: collapse; font-size: 13px; }
  td, th { padding: 3px 4px; text-align: left; border-bottom: 1px solid #eef0f3; }
  td:last-child, th:last-child { text-align: right; }
  #page { padding: 12px 14px; }

  /* the page being measured */
  .post { margin-bottom: 12px; padding: 12px; border-radius: 10px; background: #fff; }
  .post h3 { margin: 0 0 6px; font-size: 15px; }
  .post p { margin: 0 0 8px; line-height: 1.5; color: #4b5563; }
  .post ul { display: grid; grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); gap: 6px; margin: 0; padding: 0; list-style: none; }
  .post li { padding: 6px; border-radius: 6px; background: #eef4ff; font-size: 12px; }
  .auto .post { content-visibility: auto; }
  .auto.cis .post { contain-intrinsic-size: auto 260px; }
</style>
</head>
<body>
<header>
  <div class="btns">
    <button data-mode="">visible</button>
    <button data-mode="auto">auto, no size</button>
    <button data-mode="auto cis">auto + intrinsic size</button>
  </div>
  <table>
    <thead><tr><th>Run (1,500 posts)</th><th>Posts rendered</th><th>Time to first frame</th></tr></thead>
    <tbody id="runs"></tbody>
  </table>
</header>
<main id="page"></main>

<script>
  const page = document.getElementById('page');
  const runs = document.getElementById('runs');
  const N = 1500;

  // one post: a heading, text and a small grid of tags
  let tags = '';
  for (let t = 1; t <= 12; t++) tags += '<li>tag ' + t + '</li>';
  const post = (i) => '<article class="post"><h3>Post ' + i + '</h3><p>Lorem ipsum dolor sit amet, ' +
    'consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.</p><ul>' + tags + '</ul></article>';
  let html = '';
  for (let i = 1; i <= N; i++) html += post(i);

  function run(mode, label) {
    page.innerHTML = '';
    page.className = mode;
    scrollTo(0, 0);
    const t0 = performance.now();
    page.innerHTML = html;
    // two frames later, the first frame has been laid out and painted
    requestAnimationFrame(() => requestAnimationFrame(() => {
      const ms = Math.round(performance.now() - t0);
      // a post is rendered if its heading is not being skipped
      const shown = [...page.children].filter((p) =>
        p.firstElementChild.checkVisibility({ contentVisibilityAuto: true })).length;
      const row = runs.insertRow(0);
      row.innerHTML = '<td>' + label + '</td><td>' + shown + '</td><td>' + ms + ' ms</td>';
      while (runs.rows.length > 4) runs.deleteRow(4);
    }));
  }

  document.querySelectorAll('button[data-mode]').forEach((b) =>
    b.addEventListener('click', () => run(b.dataset.mode, b.textContent)));
</script>
</body>
</html>
Press each button a few times. The table shows how many posts were rendered and how long the first frame took.

Our run in headless Chromium on a desktop PC, with a 420px-tall frame, two runs per mode at 900px and 390px widths:

Mode Posts rendered Time to first frame
visible (default) 1,500 145-176 ms
auto, no size 24-26 25-34 ms
auto + intrinsic size 3-4 29-30 ms

Your numbers will differ with the device and the page. Run the demo on the phone you care about. The pattern holds: the cost of the first frame follows the number of posts rendered, not the number on the page.

Note the middle row. Without a size, skipped posts collapse, so two dozen of them fit on the screen and all get rendered. That is the problem the next section fixes.

contain-intrinsic-size keeps the scrollbar steady

While a section is skipped, it is sized as if it were empty. Without a hint, its content height is 0. The page looks much shorter than it is, the scrollbar thumb is too big, and it jumps as sections render.

Without a size hint, skipped sections collapse. With one, they hold a placeholder height.
Without a size hint, skipped sections collapse. With one, they hold a placeholder height.

In the first demo, the 40 sections gave a page 6,149px tall with auto 120px and 4,269px without it, at 900px wide. contain-intrinsic-size gives skipped sections a placeholder size:

/* width and height */
contain-intrinsic-size: auto 300px auto 500px;
/* height only (block size), in a normal top-to-bottom page */
contain-intrinsic-block-size: auto 500px;

The auto keyword matters. Once a section has been rendered, the browser remembers its real size and uses that instead of your guess when the section is skipped again.

Pick a guess close to a typical section; it only has to be right until the first render.

auto and hidden: find-in-page, focus and screen readers

content-visibility has three values. visible is the default. auto skips work but keeps the content usable. hidden hides the contents outright.

auto is a speed setting. hidden hides the contents from people and from assistive technology.
auto is a speed setting. hidden hides the contents from people and from assistive technology.

Try it below. Pick a value for the section at the bottom, then press the button, which calls focus() on a button inside that section. Then try Ctrl+F (Cmd+F on a Mac).

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>auto vs hidden: find and focus</title>
<style>
  body { margin: 0; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; font-size: 14px; }
  header {
    position: sticky; top: 0; z-index: 1; padding: 10px 14px;
    background: #fff; border-bottom: 1px solid #e1e4ea;
  }
  .modes { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-bottom: 8px; }
  header p { margin: 4px 0 0; font-size: 13px; color: #4b5563; }
  #out { font-weight: 600; color: #0f5132; }
  #out.no { color: #9a3412; }
  .spacer { height: 700px; margin: 14px; border: 2px dashed #c9cdd4; border-radius: 10px;
    display: grid; place-items: center; color: #6b7280; }
  #target {
    margin: 14px; padding: 14px; border-radius: 10px;
    background: #fff; box-shadow: 0 1px 4px rgba(0, 0, 0, .1);
  }
  #target h2 { margin: 0 0 6px; font-size: 16px; }
</style>
</head>
<body>
<header>
  <div class="modes">
    <label><input type="radio" name="cv" value="visible" checked> visible</label>
    <label><input type="radio" name="cv" value="auto"> auto</label>
    <label><input type="radio" name="cv" value="hidden"> hidden</label>
    <button id="go">Focus the button below</button>
  </div>
  <div id="out">Pick a value, then press the button.</div>
  <p>Also try Ctrl+F (Cmd+F on a Mac) and search for <b>lighthouse</b>.</p>
</header>

<div class="spacer">Scroll down, or use the controls above.</div>

<section id="target">
  <h2>The lighthouse section</h2>
  <p>This section sits far below the screen.</p>
  <button id="inner">Button inside the section</button>
</section>

<script>
  const target = document.getElementById('target');
  const inner = document.getElementById('inner');
  const out = document.getElementById('out');

  document.querySelectorAll('input[name=cv]').forEach((r) => {
    r.addEventListener('change', () => {
      target.style.contentVisibility = r.value;
      scrollTo(0, 0);
      out.className = '';
      out.textContent = 'content-visibility: ' + r.value + '. Now press the button.';
    });
  });

  document.getElementById('go').addEventListener('click', () => {
    inner.focus();
    const ok = document.activeElement === inner;
    out.className = ok ? '' : 'no';
    out.textContent = ok
      ? 'Focus reached the button and the page scrolled to it.'
      : 'Focus did not move. The button cannot be focused.';
  });
</script>
</body>
</html>
With visible and auto, focus reaches the button and Ctrl+F finds the text. With hidden, neither works.

In our test with auto, focus moved and the page scrolled to the section, and the button stayed in the accessibility tree. With hidden, focus() did nothing and the button was missing from the tree. Under hidden the contents are also not selectable.

Use hidden for panels you plan to show again, such as inactive tabs.

It works like display: none for the contents, but the element's own box stays in the layout at its placeholder size, and the browser may keep its earlier rendering work.

For a plain show and hide, show and hide a div covers the usual ways.

The contain property underneath

content-visibility is built on CSS containment. The contain property promises the browser that part of the page does not affect the rest, so the browser can limit its work to that part.

contain value What it promises Side effect you may notice
layout Nothing inside changes layout outside Becomes the containing block for fixed and absolute children
paint Nothing inside draws outside the box Children are clipped at the edge
size Its size does not depend on its children Sized as if empty, unless you set a size
style Counters and quotes stay inside Rarely visible
content Shorthand for layout, paint, style As above
strict Shorthand for size, layout, paint, style As above

content-visibility: auto turns on layout, style and paint containment all the time, and adds size containment while the element is skipped. hidden applies all four.

So the side effects in the right column apply to any element you mark auto, even while it is on screen.

Where to use it

  • Good fits: long articles split into sections, feeds of cards, comment threads, documentation pages.
  • Poor fits: the first screen of the page, which is rendered anyway, and small pages where there is little to skip.
  • Put it on repeated blocks, not on body or one wrapper around everything. A wrapper that is on screen is never skipped.

If you need to act when something scrolls into view, rather than just skip its rendering, use IntersectionObserver. For animation hints, will-change is the related property, and it solves a different problem.

When it does not work

What you see Cause Fix
The scrollbar jumps while scrolling Skipped sections have no height Add contain-intrinsic-size with auto
A dropdown or shadow is cut off at the section edge auto adds paint containment, which clips Move the popup outside the section, or leave that section without auto
A position: fixed child scrolls with the section Layout containment makes the section its containing block Move the fixed element out of the section
Nothing gets faster The property is on a wrapper that is always on screen Put it on each repeated block
Ctrl+F cannot find the text The value is hidden, not auto Use auto for content people should find
Anchor links land in the wrong spot Placeholder heights differ from real heights Set a closer contain-intrinsic-size guess

Rendering speed is easier to show than to describe. A screenshot of the timing table proves nothing to the person reading it, and an .html attachment may not open on their phone.

To let someone run the long-page test on their own device, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so they can press the buttons and see their own numbers. If you change the code later, the same link shows the new version.

Questions people ask

What does content-visibility: auto do?

It lets the browser skip style, layout and paint for the element's contents while the element is far from the screen. When the user scrolls near it, the contents are rendered as usual. The content stays in the page, so find-in-page, Tab and screen readers still reach it.

Why does my scrollbar jump after adding content-visibility: auto?

While a section is skipped, it is sized as if it had no content. The page looks shorter than it is, and it grows as sections render. Add contain-intrinsic-size with a height close to the real one, for example auto 500px.

What is the difference between content-visibility: hidden and display: none?

Both hide the contents and remove them from find-in-page, focus and the accessibility tree. With content-visibility: hidden, the element's own box stays in the layout at its placeholder size, and the browser may keep the rendering work it already did, so showing it again can be cheaper.

Does content-visibility: auto hurt accessibility?

No. Skipped content is still in the DOM and in the accessibility tree, it can be found with Ctrl+F, and it can be reached with Tab or focus(). The browser renders it as soon as it is needed. Only the hidden value removes content from these.

What happens in a browser that does not support content-visibility?

The declaration is ignored, like any CSS property the browser does not know, and the page renders every section as before. That makes it safe to add as an extra.

Keep reading