# Content visibility

> Source: <https://webperf-snippets.net/Loading/Content-Visibility>

Detect and analyze all elements using `content-visibility: auto` on a page. This CSS property is a powerful rendering optimization that allows browsers to skip layout and painting work for offscreen content, significantly improving initial page load performance.

This page provides two complementary analysis functions:

1. **`detectContentVisibility()`** - Finds all elements with `content-visibility: auto` and provides inspection tools
2. **`analyzeContentVisibilityOpportunities(options)`** - Walks the DOM to find offscreen elements that could benefit from `content-visibility: auto`, with configurable thresholds for distance, height, and child count

### Attribution

This snippet code is based on the [script](https://github.com/corewebvitals/pagespeed/blob/main/code/inp/detect-content-visibility-auto.js) by [Arjen Karel](https://www.linkedin.com/posts/arjenkarel_corewebvitals-activity-7183071762769686529-owtr)

### Snippet

```js
// Detect elements with content-visibility: auto and analyze optimization opportunities

function detectContentVisibility() {
  // Create an object to store the results
  const results = {
    autoElements: [],
    hiddenElements: [],
    visibleElements: [],
    nodeArray: [],
  };

  // Get the name of the node
  function getName(node) {
    const name = node.nodeName;
    return node.nodeType === 1 ? name.toLowerCase() : name.toUpperCase().replace(/^#/, "");
  }

  // Get the selector for an element
  function getSelector(node) {
    let sel = "";

    try {
      while (node && node.nodeType !== 9) {
        const el = node;
        const part = el.id
          ? "#" + el.id
          : getName(el) +
            (el.classList &&
            el.classList.value &&
            el.classList.value.trim() &&
            el.classList.value.trim().length
              ? "." + el.classList.value.trim().replace(/\s+/g, ".")
              : "");
        if (sel.length + part.length > 100 - 1) return sel || part;
        sel = sel ? part + ">" + sel : part;
        if (el.id) break;
        node = el.parentNode;
      }
    } catch {
      // Do nothing...
    }
    return sel;
  }

  // Check if element is in viewport
  function isInViewport(element) {
    const rect = element.getBoundingClientRect();
    return (
      rect.top < window.innerHeight &&
      rect.bottom > 0 &&
      rect.left < window.innerWidth &&
      rect.right > 0
    );
  }

  // Get element dimensions and position
  function getElementInfo(node) {
    const rect = node.getBoundingClientRect();
    const cs = window.getComputedStyle(node);

    return {
      selector: getSelector(node),
      contentVisibility: cs["content-visibility"],
      containIntrinsicSize: cs["contain-intrinsic-size"] || "not set",
      width: Math.round(rect.width),
      height: Math.round(rect.height),
      top: Math.round(rect.top + window.scrollY),
      inViewport: isInViewport(node),
    };
  }

  // Recursively find all elements with content-visibility
  function findContentVisibilityElements(node) {
    const cs = window.getComputedStyle(node);
    const cv = cs["content-visibility"];

    if (cv && cv !== "visible") {
      const info = getElementInfo(node);

      if (cv === "auto") {
        results.autoElements.push(info);
        results.nodeArray.push(node);
      } else if (cv === "hidden") {
        results.hiddenElements.push(info);
      }
    }

    for (let i = 0; i < node.children.length; i++) {
      findContentVisibilityElements(node.children[i]);
    }
  }

  // Run the detection
  findContentVisibilityElements(document.body);

  // Display results
  console.group("🔍 Content-Visibility Detection");

  if (results.autoElements.length === 0 && results.hiddenElements.length === 0) {
    console.log("%cNo content-visibility usage found.", "color: orange; font-weight: bold;");
    console.log("");
    console.log("💡 Consider applying content-visibility: auto to:");
    console.log("   • Footer sections");
    console.log("   • Below-the-fold content");
    console.log("   • Long lists or card grids");
    console.log("   • Tab content that is not initially visible");
    console.log("   • Accordion/collapsible content");
  } else {
    // Auto elements
    if (results.autoElements.length > 0) {
      console.group("✅ content-visibility: auto");
      console.log(`Found ${results.autoElements.length} element(s)`);
      console.table(results.autoElements);
      console.groupEnd();
    }

    // Hidden elements
    if (results.hiddenElements.length > 0) {
      console.group("🔒 content-visibility: hidden");
      console.log(`Found ${results.hiddenElements.length} element(s)`);
      console.table(results.hiddenElements);
      console.groupEnd();
    }

    // Check for missing contain-intrinsic-size
    const missingIntrinsicSize = results.autoElements.filter(
      (el) => el.containIntrinsicSize === "not set" || el.containIntrinsicSize === "none",
    );

    if (missingIntrinsicSize.length > 0) {
      console.group("⚠️ Missing contain-intrinsic-size");
      console.log(
        "%cThese elements lack contain-intrinsic-size, which may cause layout shifts:",
        "color: #f59e0b; font-weight: bold",
      );
      console.table(
        missingIntrinsicSize.map((el) => ({
          selector: el.selector,
          height: el.height + "px",
        })),
      );
      console.log("");
      console.log("💡 Add contain-intrinsic-size to prevent CLS:");
      console.log("   contain-intrinsic-size: auto 500px;");
      console.groupEnd();
    }

    // Nodes for inspection
    console.group("🔎 Elements for inspection");
    console.log("Click to expand and inspect in Elements panel:");
    results.nodeArray.forEach((node, i) => {
      console.log(`${i + 1}. `, node);
    });
    console.groupEnd();
  }

  console.groupEnd();

  return results;
}

// Analyze opportunities for content-visibility optimization
// Options:
//   - threshold: distance from viewport bottom to consider "offscreen" (default: 0)
//   - minHeight: minimum element height in px (default: 100)
//   - minChildren: minimum child elements to be considered (default: 5)
function analyzeContentVisibilityOpportunities(options = {}) {
  const { threshold = 0, minHeight = 100, minChildren = 5 } = options;

  const viewportHeight = window.innerHeight;
  const opportunities = [];
  const processedElements = new Set();

  function getSelector(node) {
    let sel = "";
    try {
      while (node && node.nodeType !== 9) {
        const el = node;
        const name = el.nodeName.toLowerCase();
        const part = el.id
          ? "#" + el.id
          : name +
            (el.classList && el.classList.value && el.classList.value.trim()
              ? "." + el.classList.value.trim().split(/\s+/).slice(0, 2).join(".")
              : "");
        if (sel.length + part.length > 80) return sel || part;
        sel = sel ? part + ">" + sel : part;
        if (el.id) break;
        node = el.parentNode;
      }
    } catch {}
    return sel;
  }

  function estimateRenderSavings(childCount) {
    const baseMs = childCount * 0.2;
    if (baseMs < 5) return "Low (~" + baseMs.toFixed(1) + "ms)";
    if (baseMs < 20) return "Medium (~" + baseMs.toFixed(1) + "ms)";
    return "High (~" + baseMs.toFixed(1) + "ms)";
  }

  function isAncestorProcessed(el) {
    let parent = el.parentElement;
    while (parent) {
      if (processedElements.has(parent)) return true;
      parent = parent.parentElement;
    }
    return false;
  }

  function analyzeElement(el) {
    // Skip already processed or descendant of processed
    if (processedElements.has(el) || isAncestorProcessed(el)) return;

    const rect = el.getBoundingClientRect();
    const cs = window.getComputedStyle(el);

    // Skip if already using content-visibility
    if (cs["content-visibility"] && cs["content-visibility"] !== "visible") return;

    // Skip elements not meeting size criteria
    if (rect.height < minHeight || rect.width === 0) return;

    // Check if element is below the viewport + threshold
    const distanceFromViewport = rect.top - viewportHeight;
    if (distanceFromViewport < threshold) return;

    const childCount = el.querySelectorAll("*").length;

    // Skip elements with too few children
    if (childCount < minChildren) return;

    processedElements.add(el);

    opportunities.push({
      selector: getSelector(el),
      height: Math.round(rect.height) + "px",
      distanceFromViewport: Math.round(distanceFromViewport) + "px",
      childElements: childCount,
      estimatedSavings: estimateRenderSavings(childCount),
      element: el,
    });
  }

  // Walk all elements in the DOM
  const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, null, false);

  while (walker.nextNode()) {
    analyzeElement(walker.currentNode);
  }

  // Sort by child count (highest impact first)
  opportunities.sort((a, b) => b.childElements - a.childElements);

  // Display results
  console.group("💡 Content-Visibility Opportunities");
  console.log(
    `%cSettings: threshold=${threshold}px, minHeight=${minHeight}px, minChildren=${minChildren}`,
    "color: #888;",
  );
  console.log("");

  if (opportunities.length === 0) {
    console.log("%c✅ No opportunities found with current settings.", "color: #22c55e; font-weight: bold");
    console.log("Try adjusting: analyzeContentVisibilityOpportunities({ threshold: -200, minHeight: 50, minChildren: 3 })");
  } else {
    console.log(
      `%cFound ${opportunities.length} element(s) that could benefit from content-visibility: auto`,
      "font-weight: bold;",
    );
    console.log("");

    // Show table without element reference
    const tableData = opportunities.slice(0, 20).map((opportunity) => ({
      selector: opportunity.selector,
      height: opportunity.height,
      distanceFromViewport: opportunity.distanceFromViewport,
      childElements: opportunity.childElements,
      estimatedSavings: opportunity.estimatedSavings,
    }));
    console.table(tableData);

    if (opportunities.length > 20) {
      console.log(`... and ${opportunities.length - 20} more elements`);
    }

    // Log elements for inspection
    console.log("");
    console.group("🔎 Elements for inspection");
    opportunities.slice(0, 10).forEach((opp, i) => {
      console.log(`${i + 1}. `, opp.element);
    });
    console.groupEnd();

    console.log("");
    console.group("📝 Implementation Example");
    console.log("Add this CSS to optimize rendering:");
    console.log("");
    console.log(
      "%c/* Optimize offscreen content */\n" +
        ".your-selector {\n" +
        "  content-visibility: auto;\n" +
        "  contain-intrinsic-size: auto 500px; /* Use actual height */\n" +
        "}",
      "font-family: monospace; background: #1e1e1e; color: #9cdcfe; padding: 10px; border-radius: 4px;",
    );
    console.groupEnd();
  }

  console.groupEnd();

  return {
    opportunities: opportunities.map((opportunity) => ({
      selector: opportunity.selector,
      height: opportunity.height,
      distanceFromViewport: opportunity.distanceFromViewport,
      childElements: opportunity.childElements,
      estimatedSavings: opportunity.estimatedSavings,
    })),
    totalElements: opportunities.length,
    highImpact: opportunities.filter((o) => o.estimatedSavings.startsWith("High")).length,
    elements: opportunities.map((o) => o.element),
  };
}

window.analyzeContentVisibilityOpportunities = analyzeContentVisibilityOpportunities;

// Run detection
(() => {
  const cvResults = detectContentVisibility();
  console.log(
    "%c\n To find optimization opportunities, run: %canalyzeContentVisibilityOpportunities()",
    "color: #3b82f6; font-weight: bold;",
    "color: #22c55e; font-weight: bold; font-family: monospace;"
  );
  return {
    script: "Content-Visibility",
    status: "ok",
    count: cvResults.autoElements.length + cvResults.hiddenElements.length,
    details: {
      autoCount: cvResults.autoElements.length,
      hiddenCount: cvResults.hiddenElements.length,
    },
    items: [
      ...cvResults.autoElements.map(el => ({ ...el, type: "auto" })),
      ...cvResults.hiddenElements.map(el => ({ ...el, type: "hidden" })),
    ],
    issues: [
      ...cvResults.autoElements
        .filter(el => el.containIntrinsicSize === "not set" || el.containIntrinsicSize === "none")
        .map(el => ({ severity: "warning", message: `${el.selector}: missing contain-intrinsic-size (CLS risk)` })),
      ...(cvResults.autoElements.length + cvResults.hiddenElements.length === 0
        ? [{ severity: "info", message: "No content-visibility usage found. Consider content-visibility: auto on below-the-fold sections to reduce the initial render cost." }]
        : []),
    ],
  };
})();
```

### Understanding the results

#### `detectContentVisibility()` results

This function scans the entire DOM for elements using `content-visibility` and provides:

##### Auto elements (`content-visibility: auto`)

- **selector**: CSS selector path to the element
- **contentVisibility**: The current value (`auto`)
- **containIntrinsicSize**: The placeholder size for layout calculations
- **width/height**: Current dimensions of the element
- **top**: Distance from the top of the document
- **inViewport**: Whether the element is currently visible

##### Hidden elements (`content-visibility: hidden`)

- Elements completely hidden from rendering
- Useful for off-canvas menus, modals, or pre-rendered content

##### Warnings

- Missing `contain-intrinsic-size` warnings help prevent Cumulative Layout Shift (CLS)
- Suggests appropriate height values based on current element dimensions

#### `analyzeContentVisibilityOpportunities(options)` results

This function walks the entire DOM to find elements below the viewport that would benefit from `content-visibility: auto`.

##### Options (all optional)

| Option        | Default | Description                                                                                                    |
| ------------- | ------- | -------------------------------------------------------------------------------------------------------------- |
| `threshold`   | `0`     | Distance in px from viewport bottom. Use negative values (e.g., `-200`) to include elements closer to the fold |
| `minHeight`   | `100`   | Minimum element height in px to be considered                                                                  |
| `minChildren` | `5`     | Minimum number of child elements (filters out simple containers)                                               |

##### Examples

```js copy
// Default settings
analyzeContentVisibilityOpportunities()

// More aggressive - find elements closer to viewport
analyzeContentVisibilityOpportunities({ threshold: -200 })

// Find smaller elements with fewer children
analyzeContentVisibilityOpportunities({ minHeight: 50, minChildren: 3 })

// Combine options
analyzeContentVisibilityOpportunities({ threshold: -100, minHeight: 80, minChildren: 10 })
```

##### Output fields

- **selector**: Element identifier
- **height**: Current height (use this for `contain-intrinsic-size`)
- **distanceFromViewport**: How far below the viewport the element is
- **childElements**: Number of descendant elements (more = higher impact)
- **estimatedSavings**: Rough estimate of render time saved (Low/Medium/High)

**Note:** The function automatically filters out nested elements - if a parent container is selected, its children won't appear separately in the results.

### See what it skips

Scroll down a long page one section at a time with three CSS setups, and compare the render work up front, the sections rendered along the way and what happens to the page height:

Interactive demo: [Interactive model of a long page scrolled section by section, comparing the default rendering, content-visibility auto with contain-intrinsic-size, and content-visibility auto without a size, showing the render work, the sections rendered and the page height and scrollbar in each case](https://webperf-snippets.net/demos/content-visibility-render.html). Switch the CSS setup and press the scroll button to see which sections render and when.

### How content-visibility works

The `content-visibility` CSS property controls whether an element renders its contents:

| Value     | Behavior                                                                             |
| --------- | ------------------------------------------------------------------------------------ |
| `visible` | Default. Content always rendered.                                                    |
| `auto`    | Content rendered when near viewport. Browser skips rendering for offscreen elements. |
| `hidden`  | Content never rendered (like `display: none` but preserves element state).           |

When using `content-visibility: auto`:

1. **Initial Load**: Offscreen elements are not rendered
2. **Scroll**: Elements render as they approach the viewport
3. **Memory**: Rendered content may be discarded when scrolling away

### Important: contain-intrinsic-size

Always pair `content-visibility: auto` with `contain-intrinsic-size` to prevent layout shifts:

```css copy
.offscreen-section {
  content-visibility: auto;
  contain-intrinsic-size: auto 500px;
}
```

### Understanding the value

The `contain-intrinsic-size` property tells the browser what size to use for the element before its content is rendered. This is critical because without it, offscreen elements would have zero height, causing massive layout shifts when they render.

- **`auto`**: Tells the browser to remember the actual rendered size after the first render. On subsequent navigations or when scrolling back, it uses the cached size instead of the placeholder.
- **`500px`** (or any height value): **The estimated height of your content**. This should approximate the actual rendered height of the element.

### How to choose the right height value

1. **Measure the actual element**: Use DevTools to inspect the element's rendered height, or use the `analyzeContentVisibilityOpportunities()` function which reports element heights
2. **Use an average**: For dynamic content (like comments or cards), use an average expected height
3. **Err on the side of larger**: A slightly larger estimate is better than too small, it reduces the chance of noticeable layout shifts
4. **It doesn't need to be exact**: The browser will adjust once content renders, this is just a placeholder to reserve space

### Example values by content type

| Content Type         | Typical Height |
| -------------------- | -------------- |
| Footer               | 200-400px      |
| Comments section     | 600-1000px     |
| Product card         | 300-450px      |
| Blog article section | 400-800px      |
| Sidebar widget       | 200-350px      |

### Performance impact

### Potential benefits

- **Faster Initial Render**: Browser skips layout/paint for offscreen content
- **Reduced Main Thread Work**: Less JavaScript style recalculation
- **Lower Memory Usage**: Offscreen content not in render tree
- **Improved INP**: Less work during scrolling interactions

### Typical savings

| Scenario                              | Estimated Improvement |
| ------------------------------------- | --------------------- |
| Long blog post with comments          | 100-300ms render time |
| E-commerce product grid (50+ items)   | 200-500ms render time |
| Documentation page with many sections | 150-400ms render time |
| Social feed with infinite scroll      | 300-800ms render time |

### Best practices

### Good candidates for `content-visibility: auto`

- Footer sections
- Below-the-fold content sections
- Long lists or card grids
- Comments sections
- Related content / recommendations
- Tab panels (non-active)
- Accordion/collapsible content
- Infinite scroll items

### Avoid using on

- Above-the-fold content (defeats the purpose)
- Elements with animations that need immediate rendering
- Content that affects layout calculations above it
- Very small elements (overhead not worth it)

### Browser support

| Feature                                                                                                  | Chrome | Edge | Firefox | Safari |
| -------------------------------------------------------------------------------------------------------- | :----: | :--: | :-----: | :----: |
| [content-visibility](https://developer.mozilla.org/docs/Web/CSS/Reference/Properties/content-visibility) |  ✅ 85  | ✅ 85 |  ✅ 125  |  ✅ 18  |

Source: [MDN browser compatibility data](https://github.com/mdn/browser-compat-data), version 8.1.4.

Browsers without support ignore the property, so it's safe to use as a progressive enhancement.

### Implementation examples

### Basic usage

```css copy
/* Apply to sections below the fold */
.content-section:not(:first-child) {
  content-visibility: auto;
  contain-intrinsic-size: auto 300px;
}
```

### Footer optimization

```css copy
footer {
  content-visibility: auto;
  contain-intrinsic-size: auto 400px;
}
```

### Card grid optimization

```css copy
/* Skip rendering for cards far from viewport */
.product-card:nth-child(n + 5) {
  content-visibility: auto;
  contain-intrinsic-size: 280px 350px;
}
```

### Comments section

```css copy
.comments-container {
  content-visibility: auto;
  contain-intrinsic-size: auto 800px;
}
```

### Further reading

For an in-depth understanding of `content-visibility` and rendering optimization:

- [content-visibility: the new CSS property that boosts your rendering performance](https://web.dev/articles/content-visibility) | web.dev
- [CSS Containment](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment) | MDN Web Docs
- [content-visibility on MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/content-visibility) | Complete reference
- [Improve Largest Contentful Paint with content-visibility](https://web.dev/articles/optimize-lcp#optimize_when_resources_are_loaded) | web.dev

### Real-world example

- [Add content-visibility to NoteButton component](https://github.com/nucliweb/react-workshop-fwdays-mar24/pull/2) | A practical PR showing before/after DevTools screenshots of layout and layers improvements when applying `content-visibility` to a React component

### Note

*This snippet analyzes computed styles, so it will detect `content-visibility` applied through any method (inline styles, stylesheets, or JavaScript). The analysis runs synchronously and may take a moment on pages with many elements.*
