Prism
← All primitives

CanvasPerf

Utility

Make any R3F scene smooth on any device: useDeviceTier (device heuristic for a starting quality), AutoDpr (PerformanceMonitor + AdaptiveDpr — holds framerate by trading resolution), and PauseWhenOffscreen (stop rendering when off-screen).

AutoDpr + PauseWhenOffscreen render INSIDE a <Canvas>; useDeviceTier is a plain hook the wrapper uses to pick a starting dpr/count/bloom.

$npx shadcn@latest add https://prism.icglabs.co/r/canvas-perf.json
Dependencies:@react-three/fiber@react-three/drei
View raw manifest →

lib/useDeviceTier.ts
"use client";

import { useEffect, useState } from "react";

/**
 * useDeviceTier — a cheap, synchronous device-capability heuristic that picks a
 * *starting* quality for WebGL scenes. Runtime adaptation (PerformanceMonitor +
 * AdaptiveDpr) then corrects from here, so a wrong guess self-heals — this just
 * avoids opening a heavy device at full quality and stuttering on the first frames.
 *
 * SSR-safe: returns the "high" default on the server / first render, then resolves
 * the real tier on mount.
 */
export type Quality = {
  tier: "low" | "mid" | "high";
  /** [min, max] DPR range — AdaptiveDpr scales within it by measured FPS. */
  dpr: [number, number];
  /** whether to render the (expensive) bloom pass at all. */
  bloom: boolean;
  /** instance/particle count multiplier. */
  scale: number;
};

const HIGH: Quality = { tier: "high", dpr: [0.8, 1.5], bloom: true, scale: 1 };
const MID: Quality = { tier: "mid", dpr: [0.7, 1.3], bloom: true, scale: 0.75 };
const LOW: Quality = { tier: "low", dpr: [0.6, 1], bloom: false, scale: 0.5 };

export function useDeviceTier(): Quality {
  const [q, setQ] = useState<Quality>(HIGH);

  useEffect(() => {
    const nav = navigator as Navigator & { deviceMemory?: number };
    const coarse = window.matchMedia("(pointer: coarse)").matches; // phones/tablets
    const cores = nav.hardwareConcurrency ?? 8;
    const mem = nav.deviceMemory ?? 8;
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let base: Quality;
    if (coarse || cores <= 4 || mem <= 4) base = LOW;
    else if (cores <= 6 || mem <= 6) base = MID;
    else base = HIGH;

    // reduced-motion users get the static path anyway; keep counts modest.
    setQ(reduce ? { ...base, scale: Math.min(base.scale, 0.6) } : base);
  }, []);

  return q;
}
components/showcase/CanvasPerf.tsx
"use client";

import { useEffect } from "react";
import { useThree } from "@react-three/fiber";
import { PerformanceMonitor, AdaptiveDpr } from "@react-three/drei";

/**
 * AutoDpr — drop inside any <Canvas>. PerformanceMonitor watches the real
 * framerate and AdaptiveDpr trades resolution to hold it steady (within the
 * Canvas's dpr range). If the device keeps struggling, onLow fires once so the
 * scene can shed its heaviest pass (bloom). This is the core "smooth on anything"
 * mechanism — it self-corrects no matter the device.
 */
export function AutoDpr({ onLow }: { onLow?: () => void }) {
  return (
    <>
      <PerformanceMonitor flipflops={3} onFallback={() => onLow?.()} />
      <AdaptiveDpr />
    </>
  );
}

/**
 * PauseWhenOffscreen — stops the render loop when the canvas leaves the viewport
 * and resumes it on return. Kills wasted GPU/CPU from sticky or below-fold
 * canvases that stay mounted while you're looking elsewhere.
 */
export function PauseWhenOffscreen() {
  const gl = useThree((s) => s.gl);
  const setFrameloop = useThree((s) => s.setFrameloop);

  useEffect(() => {
    const el = gl.domElement;
    // Lenient margin so we only pause when clearly off-screen; IntersectionObserver
    // also re-fires when the canvas resizes, so a briefly-mismeasured canvas resumes.
    const io = new IntersectionObserver(
      ([entry]) => setFrameloop(entry.isIntersecting ? "always" : "never"),
      { rootMargin: "200px" }
    );
    io.observe(el);
    return () => io.disconnect();
  }, [gl, setFrameloop]);

  return null;
}
Live demo — read-only. Every section is a real, copyable primitive.