3D Radial Bars
A four-state 3D radial bar chart on react-three-fiber — bars ring a center, height encodes value, theme tokens resolved at runtime for WebGL, orbit camera and hover tooltips.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-3d-radial-bars.jsonPrompt
Build a React + TypeScript + Tailwind "Chart3dRadialBars" widget on
react-three-fiber (three + @react-three/drei) with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
items: { id, label, value >= 0 }[]; max?: number > 0 }.
- Component props = z.infer of the schema, plus onRetry?: () => void and the
root div's HTML attributes (forwardRef + spread). No parallel interface.
Behavior
- Four first-class branches in one bg-card panel; loading / empty / error are
plain DOM, never a Canvas: loading mirrors the canvas box with a pulsing
ring skeleton, empty shows an outline ring + zero-data copy, error shows a
message and a "Try again" button only when onRetry exists. ready with zero
items falls back to the empty branch.
- SSR gate: the <Canvas> mounts only after hydration (useSyncExternalStore
mounted flag); until then the ready branch renders the same skeleton, so
the server never touches WebGL and hydration never mismatches.
- Scene: bar i stands on a ground circle of radius ~3 at angle i/n * 2π,
height = clamp(value / scale, 0, 1) * ~2.6 with a small floor so zero
values stay visible and hoverable; scale = max ?? tallest value. Bar
thickness follows the chord between neighbours so a dense ring thins out
instead of overlapping. Concentric ground guide rings; ambientLight +
directionalLight.
- Camera: OrbitControls with damping, pan/zoom off (no scroll hijack in a
docs page), polar angle capped above the ground; autoRotate only while
prefers-reduced-motion does not match (listen for changes and remove the
listener on unmount) and no bar is hovered — a scene that keeps spinning
drags the hovered bar out from under a resting pointer and blinks the
tooltip away.
- Hover: R3F pointer events set the hovered index (guarded reset on
pointer-out so out/over ordering can't blink it away); a drei <Html>
tooltip floats above the bar with label + value; the hovered bar lights up
via emissiveIntensity.
Rendering & styling
- WebGL materials cannot read var(--chart-1) and THREE.Color cannot parse
oklch, so resolve tokens at runtime: read
getComputedStyle(document.documentElement) for --chart-1..5 / --border /
--muted-foreground, normalize each through an offscreen 2D canvas
(fillStyle → fillRect → getImageData → "rgb(r, g, b)" plus alpha). Keep
data[3]: getImageData is un-premultiplied and dark themes make --border
translucent, so flattening to rgb would draw the faint guide rings as
opaque near-white. Re-resolve on theme flips with a MutationObserver on
the html class attribute; disconnect it on unmount. No hex/oklch literal
anywhere in the source.
- Bars: meshStandardMaterial with the resolved chart tokens, cycling i % 5;
guide rings use the resolved border / muted-foreground tokens, with
transparent + opacity = the token's alpha on the line material.
- The canvas wrapper is a fixed-aspect (aspect-[4/3]) div with role="img"
and a descriptive aria-label; an sr-only paragraph lists every label and
value; a DOM legend below repeats color dot + label + value so exact
numbers never depend on hovering a rotating object.
- Panel: rounded-xl border bg-card p-6; cn() merges className; the tooltip
uses bg-popover text-popover-foreground border; <Canvas dpr={[1,2]}>.
Customization levers
- Silhouette: ring radius (~3), max bar height (~2.6) and bar thickness set
the drama — taller and thinner reads editorial, squat and wide reads
dashboard.
- Motion: autoRotateSpeed (default 0.9) or drop autoRotate entirely; the
damping factor sets the hand-feel; re-enable zoom for exploratory use
outside scrolling pages.
- Ground: number and radii of the guide rings, or swap them for a faint
disc mesh.
- Palette: bars cycle var(--chart-1..5); pin one token per known category by
replacing the i % 5 formula.
- Density: 4–12 bars read well; past that the chord formula thins bars into
slivers — fall back to a flat bar chart instead.
- Camera: initial position / fov control how much the "3D" reads — lower
fov looks flatter and more isometric.Concepts
- SSR canvas gate — the WebGL
<Canvas>mounts only after hydration, behind auseSyncExternalStoremounted flag; the server and the first client paint both render the loading skeleton, so there is no hydration mismatch and no server-side WebGL touch. - Runtime token resolution — materials can't read
var(--chart-1)andTHREE.Colorcan't parseoklch(), so each token is painted onto a 1×1 offscreen 2D canvas and read back as rgb plus alpha — kept, becausegetImageDatais un-premultiplied and dark-mode--borderis translucent, so a flattened rgb would turn the faint rings opaque white; a MutationObserver on the html class attribute re-resolves the moment the theme flips. - Orbit under consent — the camera auto-rotates only while
prefers-reduced-motiondoesn't match (with a live change listener) and no bar is hovered, so the tooltip can't drift out from under a resting pointer; drag-to-orbit always works, and zoom/pan stay off so the chart never hijacks page scroll. - Tooltip as DOM — hover state comes from R3F pointer events and the tooltip is a drei
Htmlelement, so it's a token-styled div (bg-popover,border) that re-themes for free instead of a texture baked into the scene. - Height against an honest scale — bars scale against
max(or the tallest value), over-scale values clamp instead of bursting the scene, and zero values keep a floor height so they stay visible and hoverable; the DOM legend repeats every exact value so nothing depends on catching a rotating bar.
3D Heightmap Matrix
A four-state 3D lego-plot matrix — rows × columns of WebGL columns whose height and colour intensity both encode one value, with orbit controls and hover tooltips.
Shot Map
A four-state basketball half-court shot chart in hand-rolled SVG, with made/missed dots, a per-shot hover tooltip and an FG% header.