Using new React Activity to hide offscreen content

React 19.2 shipped with the [https://react.dev/reference/react/Activity](new Activity component).

This is intended to hide tab contents etc. when its not on-screen, caching the DOM state so restores as faster than if you hid it entirely (ie. rendering null) and preserving scroll-state. But being faster than rendering the content.

However, in my use cases, it would be great to be able to do the same operation for offscreen content. The problem is Activity adds display: none to its children, and its not certain that they are rendered at all (they could) be lazily rendered.

To fix this, I created the OffscreenActivity component. It uses IntersectionObserver and ResizeObserver to detect when the content is on-screen, and then set the Activity mode to visible. If the component is not visible, it measures the bounding box of the children, and creates a placeholder div.

You can handle the case of the content changing height, by passing a deps array, this can for example be a lastModifiedAt.toISOString(), so that when something modifies the content it is properly remeasured.

The code

import { Activity, useEffect, useRef, useState } from "react";

interface OffscreenActivityProps {
  children: React.ReactNode;
  /** When any value changes, content is briefly shown to re-measure dimensions */
  deps?: readonly unknown[];
  /** IntersectionObserver rootMargin — how far outside viewport to start rendering. Default: '200px' */
  rootMargin?: string;
}

const emptyDeps: readonly unknown[] = [];

export function OffscreenActivity({
  children,
  deps = emptyDeps,
  rootMargin = "200px",
}: OffscreenActivityProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [isIntersecting, setIsIntersecting] = useState(false);
  const [hasMeasured, setHasMeasured] = useState(false);
  const [size, setSize] = useState<{ width: number; height: number } | null>(
    null,
  );

  // IntersectionObserver — detect when container enters/leaves viewport
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const observer = new IntersectionObserver(
      ([entry]) => setIsIntersecting(entry.isIntersecting),
      { rootMargin },
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, [rootMargin]);

  // Reset measurement flag when deps change (also fires on mount)
  // eslint-disable-next-line react-hooks/exhaustive-deps
  useEffect(() => {
    setHasMeasured(false);
  }, deps);

  // Content is shown when intersecting OR when we haven't measured yet
  const shouldShow = isIntersecting || !hasMeasured;

  // ResizeObserver — measure content dimensions while showing
  useEffect(() => {
    if (!shouldShow) return;
    const el = containerRef.current;
    if (!el) return;
    const observer = new ResizeObserver(([entry]) => {
      setSize({
        width: entry.contentRect.width,
        height: entry.contentRect.height,
      });
      setHasMeasured(true);
    });
    observer.observe(el);
    return () => observer.disconnect();
  }, [shouldShow]);

  // When hidden, apply last measured size as placeholder
  const style: React.CSSProperties | undefined =
    !shouldShow && size
      ? { width: size.width, height: size.height }
      : undefined;

  return (
    <div ref={containerRef} style={style}>
      <Activity mode={shouldShow ? "visible" : "hidden"}>{children}</Activity>
    </div>
  );
}

Usage

Wrap any expensive section of your page, to not render it when off-screen (pass rootMargin to tweak how far offscreen it should be, to hide):

<OffscreenActivity>
  <HeavyDashboardWidget />
</OffscreenActivity>

If the content's size depends on some data, pass it as deps so the component knows to re-measure when its content updates:

<OffscreenActivity deps={[items.length, selectedFilter]}>
  <BigFilterableTable items={items} filter={selectedFilter} />
</OffscreenActivity>

When to use it

This works great for very horizontally tall pages, with lots of complex components. You can hide entire sections using this, speeding rendering up considerably on slower devices. Just make sure to update deps properly, otherwise content may "pop" while scrolling, which is not a good look!