bottega

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-wrapper

Usage

Usagetsx
<CanvasWrapper poster={undefined}>
  {/* children */}
</CanvasWrapper>

Props

PropTypeDefaultDescription
children*React.ReactNodeThe 3D scene to mount (a <Canvas> from @react-three /fiber, etc.).
poster*React.ReactNodeStatic 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.
classNamestring
aspectstring1 / 1CSS aspect-ratio for the mount box (e.g. "1 / 1", "16 / 9"). Default "1 / 1".
minHeightstring20remMinimum 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