Charts

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.

Preview in your theme

Loading preview…

"use client"

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

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

export interface Chart3dRibbonProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    Chart3dRibbonData {

Installation

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

Prompt

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-motion does not match (and pauses during hover); orbiting by hand always works.

On This Page