Client Only
A wrapper that renders its children only after mount — the server and the first hydration render both emit the fallback, so browser-only values can never produce a hydration mismatch.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/client-only.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ClientOnly" component — a wrapper that
renders its children only after the component has mounted in the browser, so a
subtree whose value cannot be computed on the server never participates in
hydration.
Contract
- Export a forwardRef component extending
Omit<React.HTMLAttributes<HTMLElement>, "children">; the wrapper element is
always rendered (both phases) so it can reserve space and accept ref,
className, data-* and native handlers.
- Props: children (ReactNode, required — the browser-only subtree),
fallback (ReactNode, default null — what the server and the first hydration
render output instead), as (React.ElementType, default "div" — use "span"
inside a sentence, "td"/"li" inside a table or list so the placeholder phase
stays valid HTML).
- The root carries data-client-only="pending" | "mounted" for styling and tests.
- No delay/idle/viewport props. Deferring *when* to mount is a different
component's job; this one only draws the SSR/CSR line.
Behavior
- "Has this mounted?" is read through useSyncExternalStore, NOT an effect that
calls setState:
subscribe -> () => () => {} (no-op; the value transitions once)
getSnapshot -> true
getServerSnapshot -> false
getServerSnapshot returning false is the load-bearing part: the server render
and the FIRST client (hydration) render produce byte-identical output, so the
component can never be the cause of the mismatch it exists to prevent. After
hydration React re-reads getSnapshot, sees true, and re-renders the subtree
into the real children on its own.
- Both getters must return cached primitives (constants), or React's Object.is
comparison reports a change every render ("The result of getSnapshot should be
cached to avoid an infinite loop").
- An effect-based `const [mounted, setMounted] = useState(false); useEffect(() =>
setMounted(true), [])` is the folklore version of this and is also what
eslint's react-hooks/set-state-in-effect rule rejects — a synchronous setState
in an effect body. Use the store.
- Asymmetry to preserve: in a purely client-rendered tree (createRoot, or
mounting into an already-hydrated page) React never calls getServerSnapshot,
so children appear immediately with no placeholder flash. The fallback is only
paid where server HTML actually exists.
- Children are not rendered at all before mount — not rendered-and-hidden. That
is what makes it safe for children to touch window / document / localStorage /
Date.now() / Math.random() during render or in a useState initializer.
- No animation on the swap: a fade would delay content and would then need a
prefers-reduced-motion escape hatch. The swap is instant.
Rendering & styling
- Semantic tokens only; the wrapper sets no color, spacing or radius of its own,
it only merges the consumer's className through cn(). Whatever the fallback
and children look like is the consumer's.
- Layout stability is the one real design duty. The box must be the same size in
both phases, and there are exactly two ways to get that: size the wrapper
(className="h-40") or make the fallback the same size as the children (a
skeleton, a dash placeholder, an approximation). Do one. With neither, the box
collapses to zero height until mount and the page jumps.
- Accessibility/SEO cost, to be stated and not softened: the children are absent
from server HTML. They are invisible to crawlers that do not execute JS, to
in-page find (Ctrl+F) before hydration, to the first screen-reader pass, and to
printing / "view source" of the raw document. Wrap the smallest piece that
genuinely needs the browser — never a page, a nav or an article body.
Customization levers
- Fallback strategy: skeleton bars (unknown content), a dash/placeholder string
of the same width (a value, e.g. "--:--:--"), or a server-computable
approximation (UTC time, a default preference) that gets corrected on mount —
the third one is the best for perceived quality when it is possible.
- Sizing: put a fixed height/aspect on the wrapper when the content's size is
known, otherwise shape the fallback to match. Both are className changes.
- Element: `as` covers inline ("span"), table ("td"), list ("li") and section
("section") contexts.
- Scope: prefer several small ClientOnly wrappers around the specific offending
values over one big wrapper around a section — smaller wrappers keep more of
the page in the server HTML.
- Styling by phase: data-client-only="pending" can drive a
[data-client-only=pending]:opacity-60 style rule if you want the placeholder
visually de-emphasised without a separate fallback tree.Concepts
getServerSnapshotis the whole trick — the server render and the first hydration render must return the same branch, so the mount flag is read from an external store whose server snapshot is hard-codedfalse. React re-reads the client snapshot only after hydration commits, and re-renders the subtree itself.- Not "rendered then hidden" — children are never invoked before mount, so it is safe for them to read
window,localStorageor a clock during render or in auseStateinitializer. Hiding with CSS would still execute them on the server. - Equal-size fallback, or you buy the jump — the wrapper exists in both phases; give the box its size either on the wrapper (
className="h-40") or via a fallback shaped like the real content. Passing neither collapses the box to zero and shifts everything below it. - The cost is content invisibility, not just a spinner — anything wrapped is missing from the server HTML: crawlers without JS, in-page find before hydration, the first screen-reader pass, and printing the raw document all see the fallback. It is a boundary to draw as tightly as possible, not a default page shell.
- Client-rendered trees skip the placeholder entirely — React only calls
getServerSnapshotwhile hydrating, so in a CSR-only app or when mounting into an already-hydrated page the children appear on the first frame with no flash. - A different question from "when should this mount" — deciding to defer until a subtree scrolls into view or the browser goes idle is a separate concern (
lazy-render); this component answers only "may the server render this at all".
Safe Area
A wrapper that pads or offsets content by the OS safe-area insets — notch, rounded corners, home indicator, landscape side rails — with a max() floor for devices that report none.
Density Toggle
A compact / comfortable / spacious switch that publishes seven --density-* CSS variables to a whole subtree, with a 24px target floor the compact step cannot cross.