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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-3d-ribbon.jsonPrompt
Build a React + TypeScript + Tailwind "Chart3dRibbon" card 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"; title: string;
xLabels: string[]; series: { label: string; values: number[] (>= 0) }[];
unit?: string }.
- Component props = z.infer of the schema, plus onRetry?: () => void and the
usual div props; cn() merges className, remaining props spread on the root.
- values align with xLabels by index; a missing value renders as 0, and a
zero keeps a hairline base so a lane never silently vanishes.
Behavior
- The four states are first-class branches in one bg-card panel. loading,
empty and error are plain DOM — no <Canvas> — so SSR and WebGL-less
browsers never touch a GL context.
- Mount gate: the scene renders only after a useEffect resolves the theme
palette (deferred one animation frame); until then the ready branch shows
the same skeleton as loading. SSR output never contains a Canvas.
- Token → material pipeline: THREE.Color cannot parse oklch() tokens, so
read each token with getComputedStyle, paint one pixel of an offscreen 2D
canvas with it, read the pixel back, and feed the 8-bit channels to
THREE.Color.setRGB(..., SRGBColorSpace). A MutationObserver on the <html>
class attribute re-resolves on theme flips; observer, rAF and matchMedia
listeners are all cleaned up on unmount.
- Geometry: each series is a THREE.Shape whose top edge follows its values
(x spread across a fixed span, y = value / dataset max), extruded along Z
into a ribbon and parked in its own parallel lane — first series in front,
lanes centred on the origin.
- Hover isolates: pointerover/move on a mesh stores {series, nearest x index}
(derived from event.point.x), the hovered ribbon gets a small emissive
lift, every other lane drops to low opacity, and a drei <Html> tooltip
floats above the hovered point with label · x label · value. pointerout
clears only its own series; stale indices are guarded when data reloads.
- OrbitControls with damping; autoRotate only while
prefers-reduced-motion does not match AND nothing is hovered; pan disabled
and polar angle capped so the reader cannot end up under the floor.
Rendering & styling
- Semantic tokens only: ribbons cycle the five --chart-* tokens, the floor
gridHelper uses --muted-foreground (centre) and --border (lines), the
panel is bg-card, the tooltip bg-popover / text-popover-foreground. No
hex/rgb/oklch literal anywhere — every WebGL colour comes off the live
stylesheet through the pixel-probe pipeline.
- <Canvas dpr={[1,2]} flat> (flat disables tone mapping so material colours
stay faithful to the tokens) inside a w-full aspect-[16/10] wrapper with
role="img" and a descriptive aria-label; an sr-only summary plus a full
per-point values table sit beside it for screen readers.
- ambientLight + one directionalLight; meshStandardMaterial with roughness
around 0.55, metalness 0, transparent so the dimmed opacity animates
without material recompiles.
- Below the canvas: an aria-hidden hover readout line, a legend (one row per
series with a var(--chart-N) dot and its total) and one honesty line —
depth is layout, not data.
Customization levers
- Lane rhythm: RIBBON_DEPTH and LANE_GAP set how chunky vs airy the stack
reads; SPAN_X / MAX_HEIGHT set the aspect of each ribbon.
- Camera: starting position and fov (36 = mild telephoto, less distortion);
min/max distance clamp how far the reader can dolly.
- Motion: autoRotateSpeed, or drop autoRotate entirely for a static hero;
reduced-motion behaviour must stay.
- Focus strength: dimmed opacity (0.16) and emissiveIntensity (0.22) tune
how hard hover isolates a lane.
- Palette: series cycle the five chart tokens; pin a fixed token per series
label for brand-stable lanes.
- Chrome: drop the legend and meta line for a thumbnail embed; swap the
aspect-[16/10] wrapper ratio for wide heroes.Concepts
- Mount-gated Canvas — the WebGL scene only exists after a client effect has run, so the server, hydration and WebGL-less browsers all see plain DOM; the ready-but-unmounted frame reuses the loading silhouette so nothing jumps.
- Token-to-material pipeline — WebGL has no idea what
var(--chart-1)is, so each token is painted onto a one-pixel 2D canvas and read back as RGB before entering three; the chart re-themes with the host's stylesheet instead of shipping its own colours. - Theme-flip subscription — a MutationObserver on the
<html>class attribute re-runs the pixel probe when light/dark toggles, keeping materials in lockstep with the CSS without polling; it disconnects on unmount. - Parallel Z lanes — depth encodes series identity, not a value: each ribbon owns a lane at a fixed pitch, and the card says out loud that distances between lanes mean nothing.
- Hover isolation — one pointer event decides which lane carries full colour while the rest drop to ghost opacity, and the tooltip snaps to the nearest x index derived from the 3D hit point, so reading one series never fights the other five.
- Motion consent — autoRotate is the only self-driven animation and it runs solely when
prefers-reduced-motiondoes not match (and pauses during hover); orbiting by hand always works.
3D Globe Arcs
A four-state 3D globe with graticule, value-sized location markers and elevated great-circle arcs between origin/destination pairs, coloured from the live chart tokens.
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.