Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Canvas, type ThreeEvent } from "@react-three/fiber"
import { Html, Line, OrbitControls } from "@react-three/drei"

import { cn } from "@/lib/utils"
import type { Chart3dRadialBarsData } from "./chart-3d-radial-bars.contract"

export interface Chart3dRadialBarsProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    Chart3dRadialBarsData {
  /** Omit to hide the retry affordance in the error branch entirely. */
  onRetry?: () => void

Installation

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

Prompt

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 a useSyncExternalStore mounted 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) and THREE.Color can't parse oklch(), so each token is painted onto a 1×1 offscreen 2D canvas and read back as rgb plus alpha — kept, because getImageData is un-premultiplied and dark-mode --border is 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-motion doesn'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 Html element, 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.

On This Page