three
Canvas Wrapper
The reusable 3D mount primitive that encodes Bottega's 3D contract once: WebGL feature-detect, prefers-reduced-motion fallback (null = reduced), and lazy mount via IntersectionObserver. Renders a static poster instead of the 3D scene whenever WebGL is unavailable, reduced motion is preferred, or the wrapper is offscreen. It never imports three/R3F itself — it only mounts the children scene, so every 3D component composes it.
poster fallback
Install
1. Register the namespace (once per project):
json
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
bash
npx shadcn add @bottega/canvas-wrapperUsage
Usagetsx
<CanvasWrapper poster={undefined}>
{/* children */}
</CanvasWrapper>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children* | React.ReactNode | — | The 3D scene to mount (a <Canvas> from @react-three /fiber, etc.). |
| poster* | React.ReactNode | — | Static fallback rendered instead of `children` whenever 3D must not run: WebGL unavailable, reduced-motion preferred, or before the wrapper enters the viewport. The wrapper stamps `data-testid="poster"` onto a host element around it, so a tested poster is always present on those paths. |
| className | string | ||
| aspect | string | 1 / 1 | CSS aspect-ratio for the mount box (e.g. "1 / 1", "16 / 9"). Default "1 / 1". |
| minHeight | string | 20rem | Minimum height so the box reserves space before children mount. Default "20rem". |
Source
canvas-wrapper.tsxtsx
"use client";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
export interface CanvasWrapperProps {
/** The 3D scene to mount (a <Canvas> from @react-three/fiber, etc.). */
children: React.ReactNode;
/**
* Static fallback rendered instead of `children` whenever 3D must not run:
* WebGL unavailable, reduced-motion preferred, or before the wrapper enters
* the viewport. The wrapper stamps `data-testid="poster"` onto a host element
* around it, so a tested poster is always present on those paths.
*/
poster: React.ReactNode;
className?: string;
/** CSS aspect-ratio for the mount box (e.g. "1 / 1", "16 / 9"). Default "1 / 1". */
aspect?: string;
/** Minimum height so the box reserves space before children mount. Default "20rem". */
minHeight?: string;
}
/** Synchronous WebGL feature-detect via a throwaway canvas. */
function detectWebGL(): boolean {
if (typeof document === "undefined") return false;
try {
const canvas = document.createElement("canvas");
return !!(
canvas.getContext("webgl2") ||
canvas.getContext("webgl") ||
canvas.getContext("experimental-webgl")
);
} catch {
return false;
}
}
/**
* The reusable Bottega 3D mount primitive. Encodes the §7 3D contract ONCE so
* every 3D component composes it instead of re-implementing the guards:
*
* 1. prefers-reduced-motion → render poster, mount ZERO children (null = reduced).
* 2. WebGL feature-detect → if unavailable, render poster.
* 3. lazy mount → only mount children once near-viewport
* (IntersectionObserver); poster shown until then.
*
* It never imports three/R3F itself — it only mounts whatever `children` scene
* the consumer passes, so it stays dependency-light and broadly reusable.
*/
export function CanvasWrapper({
children,
poster,
className = "",
aspect = "1 / 1",
minHeight = "20rem",
}: CanvasWrapperProps) {
const prefersReducedMotion = useReducedMotion();
// null-safe: null (SSR/first render) is treated as REDUCED so a reduced-motion
// user (or a copied component with no MotionConfig) never flashes a canvas.
const reduce = prefersReducedMotion === null || prefersReducedMotion;
const ref = useRef<HTMLDivElement>(null);
const [webgl, setWebgl] = useState(false);
const [inView, setInView] = useState(false);
// Feature-detect only after mount (avoids SSR/first-paint mismatch).
useEffect(() => {
setWebgl(detectWebGL());
}, []);
// Lazy mount: observe the box and flip inView when it nears the viewport.
useEffect(() => {
if (reduce) return; // reduced-motion never mounts a scene, so skip observing
const el = ref.current;
if (!el) return;
if (typeof IntersectionObserver === "undefined") {
setInView(true); // no IO support → mount eagerly rather than never
return;
}
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setInView(true);
io.disconnect();
}
},
{ rootMargin: "200px" }
);
io.observe(el);
return () => io.disconnect();
}, [reduce]);
// The scene mounts only when ALL guards pass.
const showScene = !reduce && webgl && inView;
return (
<div
ref={ref}
className={["relative isolate overflow-hidden rounded-[var(--radius)]", className]
.filter(Boolean)
.join(" ")}
style={{ aspectRatio: aspect, minHeight }}
>
{showScene ? (
// Live scene is decorative to AT — the poster carries any meaning.
<div aria-hidden="true" className="absolute inset-0">
{children}
</div>
) : (
<div data-testid="poster" className="absolute inset-0">
{poster}
</div>
)}
</div>
);
}
Dependencies
- motion
- @bottega/tokens