Charts

3D Funnel

A four-state pipeline funnel of stacked 3D truncated cones — orbitable with damped controls, per-stage conversion labels, and theme tokens resolved into WebGL materials at runtime.

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 { Color, SRGBColorSpace } from "three"

import { cn } from "@/lib/utils"
import type { Chart3dFunnelData, Chart3dFunnelItem } from "./chart-3d-funnel.contract"

export interface Chart3dFunnelProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    Chart3dFunnelData {
  /** Omit to hide the retry affordance in the error branch entirely. */

Installation

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

Prompt

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

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    stages: { label, value >= 0 }[]; unit?: string }.
  Stage order is trusted, never re-sorted.
- Component props = z.infer of the schema, plus onRetry?: () => void and the
  remaining div props (className merged with cn(), rest spread on the root).

Behavior
- Four first-class branches inside one bg-card panel. loading / empty /
  error are plain DOM, no WebGL: loading = a stack of shrinking pulse bars
  in the funnel's silhouette (motion-reduce aware), empty = a tiny static
  funnel glyph + zero-data copy, error = message plus a "Try again" button
  rendered only when onRetry exists.
- ready gates the <Canvas> behind a client-mount flag (useSyncExternalStore
  hydration gate; a useEffect-set mounted boolean works too): the server and
  the first client frame render the loading skeleton, so SSR never touches
  WebGL and hydration cannot mismatch.
- Geometry: N stacked truncated cones (cylinderGeometry with distinct
  top/bottom radii). Stage i's top radius = value_i / max(values), floored
  at ~5% so a heavy-drop-off tail never vanishes; its bottom radius = the
  next stage's radius, so every level tapers into the one below; a small
  vertical gap keeps the levels discrete.
- Stage labels live OUTSIDE the Canvas: an absolutely positioned,
  pointer-events-none DOM rail down the right edge of the chart area — a
  flex column, one card per level in funnel order, each with a swatch dot
  in that stage's chart token plus the label, compact value and conversion
  % from the previous stage. Stacked flex cards can never overlap or clip
  at the container edge, and they hold still while the camera orbits.
  Hovering a cone lifts its emissiveIntensity and shows a drei <Html>
  tooltip with that stage's share of the entry stage; pointer-out clears it
  with a guarded setState so crossing levels never flickers.
- OrbitControls with damping; pan and zoom disabled so the page keeps its
  scroll; autoRotate ONLY while matchMedia prefers-reduced-motion does not
  match (listener removed on unmount); polar angle clamped so the reader
  cannot flip under the funnel.

Rendering & styling
- Never hand a color literal to a material. Resolve the CSS tokens
  (--chart-1..5 for cones, --border and --muted-foreground for the grid,
  --card as compositing base) at runtime: getComputedStyle on
  documentElement, paint the value onto a 1x1 2D canvas over the card color
  (tokens may carry alpha), read the pixel back, and build a THREE.Color via
  setRGB(..., SRGBColorSpace) — THREE.Color cannot parse modern CSS color
  notation. Re-resolve when the html class attribute changes via a
  MutationObserver, disconnected on unmount.
- ambientLight + one keyed directionalLight; meshStandardMaterial per stage
  colored by the resolved var(--chart-((i mod 5) + 1)); a gridHelper floor
  uses the resolved border / muted-foreground tokens.
- The canvas wrapper is a w-full aspect-[4/3] div with role="img" and a
  descriptive aria-label; a sr-only paragraph + table mirror every stage
  (value, share of entry, conversion from previous). Canvas dpr={[1,2]}.
- All DOM (labels, tooltip, states, captions) uses semantic token classes
  only: bg-card, bg-popover, bg-muted, text-muted-foreground, border,
  focus-visible:ring-ring on the retry button.

Customization levers
- Silhouette: MAX_RADIUS / FUNNEL_HEIGHT / LEVEL_GAP set squat vs slender;
  the 5% radius floor decides how visible a collapsed tail stage stays.
- Camera & motion: camera position/fov reframe the funnel; autoRotateSpeed,
  damping and the polar-angle clamps tune how far a reader can orbit; set
  autoRotate false everywhere for a fully static hero.
- Labels: the rail is plain DOM — reposition or restyle it with utility
  classes, drop the value line for a minimal look, or move the conversion %
  into the hover tooltip only when stages are dense (7+ levels).
- Palette: stage i cycles the five chart tokens; pin all stages to one token
  and vary only emissiveIntensity for a monochrome funnel.
- Encoding: radii are linear in value; switch the ratio to sqrt(value) when
  the entry stage dwarfs the tail and the lower levels all hit the floor.

Concepts

  • SSR mount gate — the <Canvas> renders only after a client-mount flag flips; the server and the first client frame show the same DOM skeleton, so WebGL is never touched during SSR and hydration cannot mismatch.
  • Token → material pipeline — CSS custom properties are painted onto a 1×1 2D canvas (composited over the card color, since tokens may carry alpha) and the pixel is read back into a THREE.Color; the funnel re-themes itself live because a MutationObserver on the html class re-runs the resolution on every theme flip.
  • Radius encodes the value — each stage's top radius is its value over the max, each bottom radius is the next stage's, so the cones taper continuously and the silhouette is the conversion curve; a small floor keeps a collapsed tail hoverable.
  • Annotation in DOM — stage labels are a fixed rail of token-styled cards overlaid beside the canvas, so they can never overlap, clip at the container edge, or drift while the camera orbits; the hover tooltip is a DOM card projected into the scene by drei's Html. Both use the same token classes as the rest of the app instead of texture-baked text.
  • Reduced-motion contract — autoRotate is the only self-running animation and it is off under prefers-reduced-motion: reduce; the damped drag-to-orbit response to a deliberate gesture stays.
  • sr-only mirror — the wrapper is role="img" with a narrative label, and a hidden table repeats every stage with value, share of entry and step conversion, so the 3D scene never hides the numbers.

On This Page