Charts

3D Bar Grid

A four-state WebGL bar grid — categories along X, series lanes along Z, orbitable boxes on a ground grid with hover tooltips, driven by one contract.

Preview in your theme

Loading preview…

"use client"

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

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

export interface Chart3dBarsProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    Chart3dBarsData {

Installation

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

Prompt

Build a React + TypeScript + Tailwind "Chart3dBars" widget on three.js via
@react-three/fiber and @react-three/drei (Canvas, Html, OrbitControls), with zod.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    categories: string[]; series: { label: string; values: number[] }[];
    unit?: string }.
- Component props = z.infer of the schema, plus onRetry?: () => void and the
  usual div props (className merged with cn(), rest spread on the root,
  forwardRef to the card).
- values align with categories by index: missing entries render as zero,
  extras are ignored — normalize once in a memo, never in the render loop.

Behavior
- Four first-class branches in one bg-card panel: loading (pulsing bar-row
  skeleton, aria-hidden, sr-only status text), empty (icon + zero-data copy,
  also used when categories or series are empty), error (message + a
  "Try again" button only when onRetry exists), ready (the 3D scene).
- SSR must never touch WebGL: gate the Canvas behind a mounted flag that is
  false on the server and during hydration (useSyncExternalStore with a
  never-firing subscribe, or an equivalent effect-set flag); until it flips,
  the ready branch renders the same skeleton as loading.
- Scene: one box per (category, series) cell — categories along X, series
  lanes along Z, height = value / max × a fixed world height, with a small
  floor so a positive value never vanishes and a flat pad marking an exact
  zero. Boxes stand on a line-segment ground grid, one cell per column/lane,
  with axis tick labels at the grid edges.
- Hover: R3F pointer events set a { series, category } highlight (emissive
  bump on that bar) and show one drei <Html> tooltip above it with
  "series · category" and the formatted value; pointer-out clears it,
  guarded so crossing between neighbouring bars doesn't blink.
- OrbitControls with damping, pan disabled, polar angle clamped above the
  floor; autoRotate only while prefers-reduced-motion does not match AND no
  bar is hovered (listen to the media query and clean the listener up).

Rendering & styling
- Never hardcode a material color. Resolve tokens at runtime: read
  --chart-1..5 and --border with getComputedStyle, paint each value into a
  1×1 offscreen 2D canvas, read the pixel back and build a THREE.Color from
  the channels via setRGB with the sRGB color-space flag — THREE.Color
  cannot parse the modern CSS color functions design tokens are written in.
  Re-resolve when a MutationObserver sees the html element's class attribute
  change (theme flip); disconnect the observer on unmount.
- Lane i's meshStandardMaterial uses resolved chart token (i mod 5) + 1; the
  ground grid's lineBasicMaterial uses the border token; axis tick labels
  are drei <Html> spans styled text-muted-foreground so they re-theme free.
- ambientLight + one key directionalLight + a soft fill light; transparent
  canvas (default) so bg-card shows through; <Canvas dpr={[1,2]}> inside a
  fixed-aspect w-full wrapper.
- The canvas wrapper is role="img" with a descriptive aria-label; an sr-only
  block lists every series with its per-category values, and a visible
  caption states the peak value plus the orbit/zoom/hover affordances.

Customization levers
- Footprint & drama: BAR_W/BAR_D inside the 1-unit cell (city blocks vs thin
  columns) and MAX_H for the tallest bar's height.
- Camera: fov and the position formula scale with category/lane counts —
  tighten for a hero embed, widen for dense dashboards; clamp min/max
  distance to taste.
- Motion: autoRotateSpeed, damping factor, or drop autoRotate entirely; the
  reduced-motion gate must stay.
- Palette: lanes cycle the five chart tokens — remap specific series to
  fixed tokens (e.g. brand vs competitor) inside the resolver.
- Tooltip density: the drei Html card is plain token-styled DOM — add
  share-of-total or a category rank row without touching the scene.

Concepts

  • Runtime token resolution — WebGL materials can't read CSS custom properties, so the chart paints each token into an offscreen 2D pixel and hands THREE the channels; one MutationObserver re-runs it on theme flip, keeping the scene in lock-step with light/dark.
  • SSR gate — the server and the hydration pass render a plain DOM skeleton; the Canvas mounts one paint later on the client, so prerendering never touches WebGL and hydration never mismatches.
  • Two encode axes, one height channel — position encodes the two dimensions (category × series) and height alone encodes the value, so the picture stays honest: no volume or color-ramp double-encoding.
  • Hover raycast tooltip — R3F's pointer events raycast into the scene; the hit bar brightens via emissive and a single drei Html card projects above it, styled entirely with popover tokens.
  • Motion respect — auto-rotation is the decorative part and it yields twice: to prefers-reduced-motion and to an active hover; orbiting by hand keeps working either way.
  • Parallel text channel — role="img" with a data-bearing label plus an sr-only per-series value list means the scene is skippable, not silent, for screen readers.

On This Page