Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import * as THREE from "three"
import { Canvas, useFrame } from "@react-three/fiber"
import { Html, OrbitControls } from "@react-three/drei"
import { AlertCircle, RefreshCcw, Torus } from "lucide-react"

import { cn } from "@/lib/utils"
import type { Chart3dDonutData } from "./chart-3d-donut.contract"

export interface Chart3dDonutProps
  extends React.HTMLAttributes<HTMLDivElement>,
    Chart3dDonutData {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-3d-donut.json

Prompt

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 resolutionoklch() design tokens are unreadable to THREE.Color, so each one is painted through a 1x1 2D canvas and read back as sRGB bytes; a MutationObserver on the html class 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, uses tabular-nums and theme tokens, and costs no font geometry.

On This Page