3D Donut
An extruded WebGL donut on react-three-fiber — hovered slices lift and slide outward, the total floats in the hole, and a DOM legend keeps the numbers honest.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-3d-donut.jsonPrompt
Build a React + TypeScript + Tailwind "Chart3dDonut" widget on three.js via
@react-three/fiber and @react-three/drei, with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
slices: { label: string; value >= 0 }[]; centerLabel: string }.
- Component props = z.infer of the schema, plus onRetry?: () => void and the
remaining div props spread on the root (className merged with cn()). No
hand-written parallel interface.
Behavior
- The four states are first-class branches inside one bg-card panel:
- loading: an aspect-matched pulsing ring plus three legend-shaped bars,
aria-hidden, with an sr-only "Loading chart" status.
- empty: an outline torus icon + "No slices yet" + one explanatory line;
ready with zero total falls back to the same body.
- error: alert icon + message + a "Try again" button only when onRetry
exists.
- ready: the 3D donut, the floating center total, and a DOM legend below.
- SSR gate: the <Canvas> renders only after a mounted flag flips on the
client (useSyncExternalStore with a false server snapshot); until then the
ready branch shows the loading ring, so the server never touches WebGL.
- Geometry: each slice with value > 0 becomes a THREE.Shape of two absarc
calls (outer arc forward, inner arc back), extruded with no bevel and a
small angular gap; a group rotated -PI/2 about X lays the ring flat.
Dispose every geometry on unmount.
- Hover: R3F pointer events set one hovered index; the hovered slice damps
toward an outward offset along its mid-angle plus a lift along the
extrusion axis (useFrame + THREE.MathUtils.damp) while the others dim; a
drei <Html> tooltip near the slice shows label, value and share. Legend
rows mirror the same hover on pointer enter and on keyboard focus.
- Reduced motion: matchMedia("(prefers-reduced-motion: reduce)") disables
OrbitControls autoRotate and snaps the lift instead of animating it; the
listener is removed on unmount.
Rendering & styling
- Never hardcode a material colour. Resolve CSS tokens at runtime: read
getComputedStyle(document.documentElement) for --chart-1..5, paint each
value into a 1x1 offscreen 2D canvas and read the pixel back
(THREE.Color cannot parse oklch), then setRGB(..., SRGBColorSpace).
Re-resolve when the html class attribute mutates (theme flip) via a
MutationObserver subscription; disconnect it on unmount.
- <Canvas dpr={[1,2]} flat> inside an aspect-[4/3] wrapper with role="img"
and a descriptive aria-label; ambientLight + one directionalLight; one
meshStandardMaterial per slice wearing the resolved token colour;
OrbitControls with damping, no zoom/pan, clamped polar angle.
- Tooltip, center total and legend are plain DOM styled with semantic
tokens only (bg-card, bg-popover, text-muted-foreground, border,
focus-visible:ring-ring); legend dots use var(--chart-N) directly; an
sr-only paragraph lists every slice with value and share.
Customization levers
- Ring profile: OUTER_RADIUS / INNER_RADIUS / DEPTH set thin-editorial vs
chunky-showpiece; keep the hole at least half the outer radius so the
floating total stays readable.
- Hover drama: LIFT and OFFSET distances plus the damp lambda; set both to
0 for a static display piece.
- Camera: initial position and fov trade "flat and readable" against "deep
and dramatic"; the polar clamp stops readers flipping under the ring.
- Motion: autoRotateSpeed for showcase pages, or autoRotate off entirely
for dense dashboards (reduced motion already forces it off).
- Palette: slices cycle the five chart tokens through one index formula
that feeds material, legend dot and tooltip alike — re-map fixed tokens
per label by replacing that one function.
- Legend density: move the legend beside the canvas on wide cards, or drop
the share column.Concepts
- SSR canvas gate — the WebGL canvas only mounts after a client-only flag flips, and until then the ready branch wears the loading silhouette: the server renders plain DOM, hydration never diverges, and no request ever instantiates a renderer it cannot use.
- Runtime token resolution —
oklch()design tokens are unreadable toTHREE.Color, so each one is painted through a 1x1 2D canvas and read back as sRGB bytes; a MutationObserver on thehtmlclass re-runs the read, which is what makes dark mode free inside a WebGL scene. - Hover lift as focus — the hovered slice damps toward a small outward slide plus a vertical lift while its siblings dim, so attention has a physical direction; reduced motion keeps the response and drops the journey.
- DOM legend twin — the numbers never live only in pixels: the legend, the tooltip and an sr-only summary are real DOM, and hovering or keyboard-focusing a legend row drives the same highlight as pointing at the mesh.
- Center total as overlay — the headline figure is a drei
<Html>layer projected into the hole, not 3D text: it stays upright under orbit, usestabular-numsand theme tokens, and costs no font geometry.
3D Ribbon Series
A four-state multi-series 3D ribbon chart on react-three-fiber — one extruded lane per series, theme tokens resolved into WebGL materials, hover isolates a ribbon.
3D Network Graph
A four-state 3D node-link graph on react-three-fiber — degree-sized spheres, one line-segment pass for all edges, a deterministic golden-angle layout and theme tokens resolved into WebGL materials at runtime.