← Catalog
Edit Props (live)
JSON
TEXT
config
config
config
config
config
config
config
Source
452 lines// Extruded Bar Chart 3D — the WebGL answer to animated_bar_chart. Real
// 3D boxes with perspective, depth and lighting grow in (spring, staggered)
// from a JSON array of { label, value }, on a dark floor, with a gentle sway
// and emissive top-cap glow. Renders through @remotion/three (ThreeCanvas)
// driven entirely by useCurrentFrame() so Lambda's out-of-order, software-GL
// (swangle) render is frame-deterministic.
//
// Determinism contract:
// - NO useFrame (r3f). Every transform is recomputed from useCurrentFrame()
// each render and applied as a prop — pure function of the frame.
// - NO wall-clock / random / rAF — the per-bar settle wobble is seeded from
// a fixed integer hash (mulberry-ish) at module scope, never at render.
// - NO postprocessing / bloom (swangle can't do it) — glow is the emissive
// top cap; the 2D value labels are projected to sit exactly under the bars.
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from 'remotion';
import { ThreeCanvas } from '@remotion/three';
import * as THREE from 'three';
import { loadFont as loadGrotesk } from '@remotion/google-fonts/SpaceGrotesk';
const { fontFamily: GROTESK } = loadGrotesk();
type Datum = { label: string; value: number };
export type ExtrudedBarChart3DProps = {
data: Datum[] | string;
title: string;
barColor: string;
accentColor: string;
floorColor: string;
backgroundColor: string;
textColor: string;
cameraDistance: number;
orbitAmount: number;
};
// ── Deterministic helpers (module scope — never per-render) ───────────────────
/** Tiny integer hash → [0,1). Pure, stable, no Math.random. */
function hash01(n: number): number {
let t = (n + 0x6d2b79f5) >>> 0;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
function parseData(data: Datum[] | string): Datum[] {
let raw: unknown = data;
if (typeof data === 'string') {
try {
raw = JSON.parse(
data.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '')
);
} catch {
raw = [];
}
}
if (!Array.isArray(raw)) return [];
return raw.slice(0, 8).map((d) => ({
label: String((d as Datum)?.label ?? ''),
value: Number((d as Datum)?.value) || 0,
}));
}
/**
* Validate a hex color string, returning a safe hex (never throws, never
* silently white). THREE.Color does NOT throw on bad input — it warns and
* defaults to white — so we must validate the string ourselves. Returned hex
* is used for BOTH the GL materials and the CSS background so they can't drift.
*/
function safeHex(c: string, fallback: string): string {
const s = typeof c === 'string' ? c.trim() : '';
return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s) ? s : fallback;
}
// Reusable geometry — created once, never per frame, never per bar.
const BAR_GEO = new THREE.BoxGeometry(1, 1, 1);
// ── The 3D scene (rendered inside <ThreeCanvas>) ─────────────────────────────
type SceneProps = {
bars: Datum[];
frame: number;
fps: number;
barColor: THREE.Color;
accentColor: THREE.Color;
floorColor: THREE.Color;
bgColor: THREE.Color;
orbitAmount: number;
};
function Scene({
bars,
frame,
fps,
barColor,
accentColor,
floorColor,
bgColor,
orbitAmount,
}: SceneProps) {
const count = Math.max(1, bars.length);
const max = Math.max(1, ...bars.map((b) => b.value));
// Layout: spread bars across X, centered on origin.
const gap = 2.0;
const totalWidth = (count - 1) * gap;
const startX = -totalWidth / 2;
// Gentle sway (not a continuous orbit) so the bars stay roughly front-facing
// and keep alignment with the 2D value labels, while still showing 3D
// parallax. orbitAmount=0 locks it dead-on. Pure function of frame.
const orbit = Math.sin((frame / fps) * 0.45) * 0.1 * orbitAmount; // radians
const bob = Math.sin((frame / fps) * 0.9) * 0.1;
// Intro: the whole rig eases up from below in the first ~16 frames.
const rigRise = interpolate(frame, [0, 16], [-2.2, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
return (
<group position={[0, rigRise + bob, 0]} rotation={[0, orbit, 0]}>
{/* Reflective-ish floor (lambert, dark). */}
<mesh
rotation={[-Math.PI / 2, 0, 0]}
position={[0, 0, 0]}
receiveShadow={false}
>
<planeGeometry args={[40, 40]} />
{/* Lambert (not standard PBR): the floor is the largest plane on
screen — cheapest per-pixel path on Lambda's software GL, no
metalness/roughness needed for a dark grounding surface. */}
<meshLambertMaterial color={floorColor} />
</mesh>
{/* Faint floor grid lines, faked with thin emissive strips along Z. */}
{Array.from({ length: count }).map((_, i) => {
const x = startX + i * gap;
return (
<mesh
key={`grid-${i}`}
rotation={[-Math.PI / 2, 0, 0]}
position={[x, 0.01, 0]}
>
<planeGeometry args={[0.04, 14]} />
<meshBasicMaterial color={accentColor} transparent opacity={0.12} />
</mesh>
);
})}
{bars.map((b, i) => {
const grow = spring({
frame: frame - 10 - i * 5,
fps,
config: { damping: 16, stiffness: 95, mass: 0.7 },
durationInFrames: 30,
});
const targetH = (b.value / max) * 4.5 + 0.001; // world units
const h = Math.max(0.001, targetH * grow);
const x = startX + i * gap;
const width = 1.2;
const depth = 1.2;
// Color ramps slightly toward the accent for taller bars.
const t = (b.value / max) * 0.6;
const bodyColor = barColor.clone().lerp(accentColor, t);
// Emissive top-cap glow rises as the bar settles.
const emissiveStrength = 0.25 + grow * 0.55;
// Per-bar deterministic settle wobble — decays to 0 by ~frame settle.
const wobblePhase = hash01(i * 17 + 3) * Math.PI * 2;
const wobbleDecay = interpolate(grow, [0.7, 1], [1, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const tilt =
Math.sin((frame / fps) * 8 + wobblePhase) * 0.05 * wobbleDecay;
return (
<group key={i} position={[x, 0, 0]} rotation={[0, 0, tilt]}>
{/* Main extruded bar body (sits on the floor, grows upward). */}
<mesh
geometry={BAR_GEO}
position={[0, h / 2, 0]}
scale={[width, h, depth]}
>
<meshStandardMaterial
color={bodyColor}
roughness={0.32}
metalness={0.35}
emissive={bodyColor}
emissiveIntensity={0.08}
/>
</mesh>
{/* Emissive top cap — the "glow" source (no bloom needed). */}
<mesh
geometry={BAR_GEO}
position={[0, h + 0.06, 0]}
scale={[width * 1.02, 0.12, depth * 1.02]}
>
<meshStandardMaterial
color={accentColor}
emissive={accentColor}
emissiveIntensity={emissiveStrength * 2.2}
roughness={0.2}
metalness={0.1}
/>
</mesh>
</group>
);
})}
{/* Lighting: soft ambient fill + key directional + colored rim point. */}
<ambientLight intensity={0.55} />
<directionalLight position={[6, 12, 8]} intensity={1.15} />
<pointLight
position={[-6, 6, -4]}
intensity={0.9}
color={accentColor}
distance={40}
decay={2}
/>
{/* Subtle fog-colored hemisphere to ground the palette. Colors passed
as props (not constructor args) so r3f re-applies them via setters
on prop change — constructor args would bake in at mount. */}
<hemisphereLight
intensity={0.35}
color={barColor}
groundColor={bgColor}
/>
</group>
);
}
// ── Block component ──────────────────────────────────────────────────────────
export default function ExtrudedBarChart3D({
data = [
{ label: 'Jan', value: 32 },
{ label: 'Feb', value: 54 },
{ label: 'Mar', value: 71 },
{ label: 'Apr', value: 48 },
{ label: 'May', value: 89 },
],
title = '',
barColor = '#5b3df5',
accentColor = '#22d3ee',
floorColor = '#0b0b18',
backgroundColor = '#06060c',
textColor = '#ffffff',
cameraDistance = 13,
orbitAmount = 1,
}: Partial<ExtrudedBarChart3DProps>) {
const frame = useCurrentFrame();
const { fps, durationInFrames, width, height } = useVideoConfig();
// Resolution-independent overlays: HTML px authored at a 1080-wide
// reference, scaled by k. k=1 at 1080; scales cleanly at any size (the
// GL scene is already resolution-independent via world units + fov).
const k = width / 1080;
const bars = parseData(data);
const count = Math.max(1, bars.length);
const max = Math.max(1, ...bars.map((b) => b.value));
// Validate colors once into safe hex, used for BOTH GL + CSS (no drift).
const barHex = safeHex(barColor, '#5b3df5');
const accentHex = safeHex(accentColor, '#22d3ee');
const floorHex = safeHex(floorColor, '#0b0b18');
const bgHex = safeHex(backgroundColor, '#06060c');
const textHex = safeHex(textColor, '#ffffff');
const barCol = new THREE.Color(barHex);
const accentCol = new THREE.Color(accentHex);
const floorCol = new THREE.Color(floorHex);
const bgCol = new THREE.Color(bgHex);
// Camera + label framing derived from ONE perspective projection so the
// 2D value labels always sit under their 3D bars, for any bar count / zoom.
// The camera sits at x=0 (centered) and r3f points it at the origin, so its
// right axis is world X and a bar at world x projects to screen fraction
// 0.5 + 0.5 * x / (depth * tan(fov/2) * aspect),
// where depth = camera→origin distance = camDist * sqrt(camYFrac^2 + 1).
// We pick camDist so the OUTER bars land at ±TARGET_FIELD of center, then
// reuse the SAME projection for the label row width — they can't drift.
const gap = 2.0;
const totalWidth = (count - 1) * gap;
const camYFrac = 0.5; // camera height as a fraction of camDist (3/4 angle)
const depthFactor = Math.sqrt(camYFrac * camYFrac + 1);
const tanHalfFov = Math.tan((38 * Math.PI) / 180 / 2);
const aspect = width / height;
const TARGET_FIELD = 0.72; // outer bar centers span ~72% of frame width
// depth (camera→origin) that frames the outer bars at ±TARGET_FIELD:
const fitDepth =
totalWidth > 0 ? totalWidth / 2 / (TARGET_FIELD * tanHalfFov * aspect) : 18;
const zoom = Math.max(0.5, cameraDistance / 13); // prop = zoom multiplier
const camDist = Math.max(7, (fitDepth / depthFactor) * zoom);
const depth = camDist * depthFactor;
// x / projDen = NDC x; screenFracX = 0.5 + 0.5 * x / projDen. Each label is
// absolutely placed at its bar's projected screen-X with this. (The sway is
// <=5deg so its effect on label X is <1% — safely ignored here.)
const projDen = depth * tanHalfFov * aspect;
const startX = -totalWidth / 2; // leftmost bar center, world units
// Whole-block fade at the tail so it cuts cleanly in a sequence.
const exit = interpolate(
frame,
[durationInFrames - 12, durationInFrames - 1],
[1, 0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
);
const titleIn = interpolate(frame, [0, 14], [0, 1], {
extrapolateRight: 'clamp',
});
return (
<AbsoluteFill
style={{
backgroundColor: bgHex,
fontFamily: GROTESK,
opacity: exit,
overflow: 'hidden',
}}
>
{/* Radial vignette behind the GL for depth (pure CSS, frame-independent). */}
<div
style={{
position: 'absolute',
inset: 0,
background: `radial-gradient(120% 90% at 50% 32%, ${accentHex}22 0%, ${bgHex} 62%)`,
}}
/>
<ThreeCanvas
width={width}
height={height}
style={{ position: 'absolute', inset: 0 }}
camera={{
position: [0, camDist * camYFrac, camDist],
fov: 38,
near: 0.1,
far: 200,
}}
gl={{ antialias: true, alpha: true }}
>
<Scene
bars={bars}
frame={frame}
fps={fps}
barColor={barCol}
accentColor={accentCol}
floorColor={floorCol}
bgColor={bgCol}
orbitAmount={orbitAmount}
/>
</ThreeCanvas>
{/* Title (HTML overlay — crisp text, frame-driven fade). */}
{title ? (
<div
style={{
position: 'absolute',
top: '7%',
left: 0,
right: 0,
textAlign: 'center',
color: textHex,
fontSize: 64 * k,
fontWeight: 800,
letterSpacing: '-0.02em',
textShadow: '0 6px 28px rgba(0,0,0,0.6)',
opacity: titleIn,
transform: `translateY(${interpolate(
titleIn,
[0, 1],
[-18 * k, 0]
)}px)`,
pointerEvents: 'none',
}}
>
{title}
</div>
) : null}
{/* Value + label chips, each ABSOLUTELY positioned at its bar's projected
screen-X (same projection as the camera framing) so they track the 3D
bars exactly — no flex/space-between inset. Staggered with the same
spring timing as the bars so label + bar read as one motion. */}
{bars.map((b, i) => {
const grow = spring({
frame: frame - 10 - i * 5,
fps,
config: { damping: 16, stiffness: 95, mass: 0.7 },
durationInFrames: 30,
});
const screenX = 0.5 + (0.5 * (startX + i * gap)) / projDen; // 0..1
return (
<div
key={i}
style={{
position: 'absolute',
bottom: '7%',
left: `${screenX * 100}%`,
transform: `translateX(-50%) translateY(${interpolate(
grow,
[0, 1],
[14 * k, 0]
)}px)`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
opacity: grow,
pointerEvents: 'none',
}}
>
<div
style={{
color: textHex,
fontSize: 40 * k,
fontWeight: 700,
fontVariantNumeric: 'tabular-nums',
textShadow: '0 2px 12px rgba(0,0,0,0.7)',
}}
>
{Math.round(b.value * grow)}
</div>
<div
style={{
color: textHex,
opacity: 0.7,
fontSize: 30 * k,
fontWeight: 500,
marginTop: 6 * k,
letterSpacing: '0.04em',
whiteSpace: 'nowrap',
}}
>
{b.label}
</div>
</div>
);
})}
</AbsoluteFill>
);
}
Schema (Zod)
import { z } from 'zod';
const datumSchema = z.object({
label: z.string(),
value: z.number(),
});
export const Schema = z.object({
data: z
.union([z.array(datumSchema), z.string()])
.default([
{ label: 'Jan', value: 32 },
{ label: 'Feb', value: 54 },
{ label: 'Mar', value: 71 },
{ label: 'Apr', value: 48 },
{ label: 'May', value: 89 },
])
.describe('$port:json Array of { label, value } (up to 8 bars)'),
title: z.string().default('').describe('$port:text Chart title (optional)'),
barColor: z
.string()
.default('#5b3df5')
.describe('$style:color Bar base color'),
accentColor: z
.string()
.default('#22d3ee')
.describe('$style:color Accent / glow color'),
floorColor: z
.string()
.default('#0b0b18')
.describe('$style:color Floor color'),
backgroundColor: z
.string()
.default('#06060c')
.describe('$style:color Background color'),
textColor: z
.string()
.default('#ffffff')
.describe('$style:color Label / value text color'),
cameraDistance: z
.number()
.default(13)
.describe('$style:scale Camera distance (zoom out)'),
orbitAmount: z
.number()
.default(1)
.describe('$style:scale Orbit speed multiplier (0 = locked)'),
});
Extruded Bar Chart 3D
extruded_bar_chart_3dWebGL data-viz: real 3D extruded bars with perspective, depth, lighting and a slow orbit grow in (spring, staggered) from a JSON array of { label, value }, each counting up to its value. The 3D answer to Animated Bar Chart.
Workflow Inputs (2)
- Array of { label, value } (up to 8 bars)
dataJSON - Chart title (optional)
titleTEXT
Config Fields (7)
barColortextColorfloorColoraccentColororbitAmountcameraDistancebackgroundColor
Meta
- Updated
- 7/10/2026