Lazy Render
Defers mounting a subtree until it scrolls into view or the browser goes idle, holding its space with an explicitly reserved placeholder height.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/lazy-render.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "LazyRender" component — a wrapper that
delays MOUNTING its children (not just animating them in) until a trigger
fires, while reserving their space up front.
Contract
- forwardRef<HTMLDivElement>, extends
Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "placeholder">;
spread the rest onto the root div so consumers keep ref, data-*, events.
- Props: children (ReactNode, required); placeholderHeight (number | string,
REQUIRED — number is px, string is any CSS length); strategy = "visible" |
"idle" | "immediate" (default "visible"); rootMargin (default "200px");
once (default true); idleTimeout (ms, default 2000); placeholder
(ReactNode rendered inside the reserved box); disabled (boolean).
- placeholderHeight is deliberately required and deliberately not inferred:
it is the one number the component cannot know. Reserve too little and the
mount still shifts the page; too much and the gap collapses. Non-finite or
negative numbers clamp to 0 (reserve nothing) instead of throwing.
Behavior
- State starts false on the server AND on the first client paint, so both
renders emit the same placeholder markup and hydration never mismatches.
Nothing reads window / IntersectionObserver during render — every probe
lives in an effect.
- strategy="visible": in an effect, build ONE IntersectionObserver on the root
with { rootMargin } and observe it. On an intersecting entry, mount; if
once, disconnect immediately. If once is false, an entry that stops
intersecting unmounts the subtree again (cheapest memory, but all child
state is destroyed and the box snaps back to the reserved height).
- strategy="idle": requestIdleCallback(cb, { timeout: idleTimeout }) — the
timeout is what stops a busy page from starving it. Safari still does not
ship requestIdleCallback, so feature-detect and fall back to
setTimeout(cb, idleTimeout); without that fallback the subtree would never
mount there at all. Cancel with cancelIdleCallback / clearTimeout on
unmount.
- strategy="immediate": mount in a mount effect — i.e. right after hydration,
no waiting. Use it as a runtime-switchable "off" for the deferral while
keeping the SSR placeholder.
- disabled: render children inline, always, including on the server. No
observer, no placeholder, no deferral — the escape hatch.
- Fail open, never fail hidden: if IntersectionObserver is missing, or the
IntersectionObserver constructor throws (a malformed rootMargin string
raises SyntaxError), mount the children immediately instead of leaving a
subtree that can never appear or letting the page crash.
- Cleanup is mandatory: disconnect the observer, cancel the idle callback and
clear the timeout in the effect's cleanup.
- rootMargin only expands the ROOT (viewport) rectangle. An ancestor with
overflow clipping is applied to the target first, unexpanded — so inside a
scrollable panel a preload margin buys exactly nothing (measured: identical
mount point at 0px and 400px). Preloading works against page scroll.
Rendering & styling
- Root: cn() merges the consumer className; data-lazy-render =
"placeholder" | "mounted" | "disabled" for styling/testing; aria-busy while
still deferred. Reserved box: a plain div with min-height set from
placeholderHeight and aria-hidden (a skeleton is decoration, not content) —
note it only sets min-height, so a placeholder that must fill it needs its
own height.
- Semantic tokens only; the component itself paints nothing — colors come
from whatever placeholder/children you pass (bg-muted, border-dashed,
text-muted-foreground for a skeleton).
- Motion: the swap is deliberately not animated, so there is nothing for
prefers-reduced-motion to disable. A fade-in would add a second visual
change on top of a mount that is already the risky moment. If you pass an
animated skeleton as placeholder, that animation is yours to gate with
motion-reduce.
- THE HONEST COST: an unmounted subtree is not in the DOM, therefore
find-in-page (Ctrl/⌘+F) cannot find it, screen readers cannot reach it,
print does not print it, and in-page # anchors into it do not resolve.
That is not a bug to paper over — it is the price of not paying the mount
cost. Defer chrome and visuals, never the primary content of the page, and
keep `disabled` as the switch for the cases where reachability wins.
Customization levers
- Trigger: "visible" for anything below the fold; "idle" for work that must
happen anyway but not during the first-paint burst (analytics panels,
prefetched editors); "immediate" to keep only the SSR-placeholder behavior.
- Preload distance: rootMargin "200px" is a conservative default; "0px"
mounts exactly at the edge, "600px"–"1000px" hides slower widgets behind a
fast scroll — the cost is mounting things some users never reach.
- Reservation: measure the real subtree once and hard-code that number; use a
CSS length ("24rem", "50vh") when the block is fluid. Pair it with a
skeleton in `placeholder` so the reserved space reads as intentional.
- Memory vs. state: once=true (default) keeps the subtree alive after the
first mount; once=false trades child state (form input, video position,
chart animation) for a smaller live tree — only worth it for very heavy,
stateless blocks.
- Print/accessibility escape: flip `disabled` from a prop of your own (a
window.matchMedia("print") / beforeprint listener, an "expand all" control,
or a build-time flag for crawlers) whenever the content must be reachable.
- Reachability without deferring: if you only want to skip layout/paint work
but keep the DOM searchable, CSS `content-visibility: auto` +
`contain-intrinsic-size` is the cheaper trade — this component is for when
the JS mount itself is the cost.Concepts
- Deferring the mount, not the entrance — the subtree does not exist until the trigger fires: no component code runs, no effects, no third-party init, no DOM nodes. That is the whole point, and it is what separates this from an entrance animation, where everything is already mounted and merely transparent.
- Reserved height is a promise, not a hint —
placeholderHeightis required because it is the only number the component cannot derive from something that has not rendered yet. Keep the promise and the swap is invisible; under-reserve by 144px and the mount pushes everything below down by exactly 144px, which is the layout shift you were trying to avoid. rootMarginis a preload budget against the viewport only — it grows the root rectangle so mounting starts before the block is on screen. An ancestor's overflow clip is applied first and is not grown, so inside a scrollable panel the same margin changes nothing; preloading is a page-scroll feature.requestIdleCallbackneeds both a timeout and a fallback — thetimeoutoption keeps a busy page from starving the callback forever, and thesetTimeoutbranch is the only thing that ever mounts the subtree in Safari, which still does not implement the API.- Fail open, never fail hidden — no
IntersectionObserver, or a malformedrootMargin(its constructor throwsSyntaxError), degrades to "mount it now". A deferral primitive that can strand content in a permanent placeholder is worse than one that occasionally defers nothing. - Unmounted content is unreachable content — find-in-page, screen readers, printing and
#anchorlinks all operate on the DOM, so anything deferred is invisible to them until it mounts. Defer decoration and heavy widgets, never the page's primary text, and keepdisabled(or CSScontent-visibility: auto, which keeps the DOM) for the cases where being findable matters more than the mount cost.
Stick To Bottom
A scroll container that follows its own bottom edge — appended content stays in view until the reader scrolls up, and then the position is theirs, with a counted way back.
Virtual Grid
Two-dimensional windowing — only the cells near the viewport exist in the DOM, with fixed or per-track measured row heights and column widths, and no virtualization library.