Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export type LazyRenderStrategy = "visible" | "idle" | "immediate"

export interface LazyRenderProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "placeholder"> {
  children: React.ReactNode
  /**
   * Space to reserve while the subtree is still unmounted. Number = px, string = any CSS length
   * ("24rem", "50vh"). REQUIRED on purpose: it is the one number the component cannot infer, and
   * getting it wrong is the whole failure mode — too small and the mount still shifts the page,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/lazy-render.json

Prompt

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 hintplaceholderHeight is 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.
  • rootMargin is 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.
  • requestIdleCallback needs both a timeout and a fallback — the timeout option keeps a busy page from starving the callback forever, and the setTimeout branch 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 malformed rootMargin (its constructor throws SyntaxError), 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 #anchor links 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 keep disabled (or CSS content-visibility: auto, which keeps the DOM) for the cases where being findable matters more than the mount cost.

On This Page