About
Tiles are an instanced mesh of thin boxes placed on a Fibonacci sphere (even to the eye, with no pole), each turned to face outward, in a metal that reflects the stage's studio. The flecks are real: every frame, for each tile and each of three lamps, the tile's current normal gives the reflected ray of the lamp's light, which is intersected with a sphere that stands for the room and drawn as one additive point. Tiles that face away from a lamp throw nothing, and flecks that would land between the ball and the viewer are hidden. All of it is CPU work on about two thousand rays per frame.
Use it when
- A club, bar, party, DJ or events page wants a centrepiece.
- A wedding or birthday brand wants a celebration object.
- A music or nightlife app wants a mark.
Skip it when
- The page is quiet and daylight.
- The page already has a 3D centrepiece.
Install
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the component with the shadcn CLI:
npx shadcn@latest add @vibebox/mirror-ball - 3Use it:
import { MirrorBall } from "@/components/vibebox/mirror-ball"; <div className="rounded-xl bg-[#05060b]"> <MirrorBall className="aspect-[16/10] w-full" colors={["#ff4fb6", "#7a4cff", "#35d8ff"]} /> </div>
Code
3 filescomponents/vibebox/mirror-ball/index.tsx
/**
* VibeBox Motion · Mirror Ball
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
export { MirrorBall, type MirrorBallProps } from "./mirror-ball.client";
export type { MirrorBallOptions } from "./mirror-ball.sculpture";components/vibebox/mirror-ball/mirror-ball.client.tsx
/**
* VibeBox Motion · Mirror Ball
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
"use client";
import { useCallback } from "react";
import { SculptureCanvas } from "@/lib/vibebox/sculpture-canvas";
import type { MirrorBallOptions } from "./mirror-ball.sculpture";
export type MirrorBallProps = MirrorBallOptions & {
/** Accessible name. */
label?: string;
className?: string;
};
/* A glowing ball with a string, in CSS, until the 3D one is ready and wherever WebGL is not. */
function BallPoster({ colors }: { colors: readonly [string, string, string] }) {
return (
<span aria-hidden="true" className="absolute inset-0 grid place-items-center">
<span className="absolute top-0 h-[26%] w-px bg-white/30" />
<span
className="relative aspect-square w-[42%] rounded-full"
style={{
background: `radial-gradient(circle at 35% 30%, #fff, ${colors[0]} 38%, #1a1d2a 70%), repeating-conic-gradient(rgb(255 255 255 / 0.18) 0 6deg, transparent 6deg 12deg)`,
boxShadow: `0 0 80px 10px color-mix(in oklab, ${colors[1]} 40%, transparent)`,
}}
/>
</span>
);
}
export function MirrorBall({ label = "A spinning mirror ball throwing flecks of coloured light across a dark room", className, ...options }: MirrorBallProps) {
const load = useCallback(() => import("./mirror-ball.sculpture").then((module) => module.createMirrorBall), []);
const colors = options.colors ?? (["#4aa8ff", "#ff4fb6", "#ffd27a"] as const);
return <SculptureCanvas slug="mirror-ball" load={load} options={options} label={label} poster={<BallPoster colors={colors} />} className={className} />;
}components/vibebox/mirror-ball/mirror-ball.sculpture.ts
/**
* VibeBox Motion · Mirror Ball
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
import * as THREE from "three";
import { clamp01, damp, disposeObject, easeOutCubic } from "@/lib/vibebox/engine/three";
import type { Sculpture, SculptureFrame } from "@/lib/vibebox/engine/three";
/*
* A mirror ball over a dark room. Hundreds of small mirror tiles on a sphere; coloured lamps
* shine on it from three directions, and for every tile and every lamp the reflected ray is
* followed to the room's walls, where it lands as a bright fleck. As the ball turns, the flecks
* sweep the walls the way they do in a real club. The tiles themselves reflect the stage's
* studio, so they flash as they pass the key light.
*/
export type MirrorBallOptions = {
/** The three lamps' colours (any CSS colours). */
colors?: readonly [string, string, string];
/** About how many tiles (default 640). */
tiles?: number;
/** Turns per minute (default 5). */
rpm?: number;
};
const LAMPS = [new THREE.Vector3(-3.4, 1.8, 2.8), new THREE.Vector3(3.6, 0.6, 2.2), new THREE.Vector3(0.4, 3.4, 3.2)];
const ROOM = 3.4;
export function createMirrorBall(options: MirrorBallOptions = {}): Sculpture {
const colors = options.colors ?? ["#4aa8ff", "#ff4fb6", "#ffd27a"];
const rpm = options.rpm ?? 5;
const object = new THREE.Group();
const ball = new THREE.Group();
object.add(ball);
// Tiles in rows of latitude, like a real ball: each row holds as many as fit its circle, and
// every tile is sized to its cell with a hair of grout. A tile's face is knocked a degree or
// two off true, so the flecks and the glints are not a perfect grid.
const radius = 0.86;
const wanted = Math.max(120, Math.min(1400, options.tiles ?? 640));
const cell = Math.sqrt((4 * Math.PI * radius * radius) / wanted);
const rows = Math.max(6, Math.round((Math.PI * radius) / cell));
const rowHeight = (Math.PI * radius) / rows;
const hash = (n: number) => {
const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
};
const placed: { normal: THREE.Vector3; face: THREE.Vector3; width: number; tone: number }[] = [];
for (let row = 0; row < rows; row++) {
const lat = -Math.PI / 2 + ((row + 0.5) * Math.PI) / rows;
const ring = Math.cos(lat);
const perRow = Math.max(4, Math.floor((2 * Math.PI * radius * ring) / rowHeight));
for (let k = 0; k < perRow; k++) {
const lon = ((k + (row % 2) * 0.5) / perRow) * Math.PI * 2;
const normal = new THREE.Vector3(ring * Math.cos(lon), Math.sin(lat), ring * Math.sin(lon));
const seed = row * 131 + k;
const face = normal.clone().add(new THREE.Vector3(hash(seed) - 0.5, hash(seed + 7) - 0.5, hash(seed + 13) - 0.5).multiplyScalar(0.06)).normalize();
placed.push({ normal, face, width: ((2 * Math.PI * radius * ring) / perRow) * 0.92, tone: 0.84 + 0.16 * hash(seed + 29) });
}
}
const count = placed.length;
const normals = placed.map((tile) => tile.face);
const sites = placed.map((tile) => tile.normal);
const tileGeometry = new THREE.BoxGeometry(1, 1, 0.2);
const tileMaterial = new THREE.MeshStandardMaterial({ color: 0xffffff, metalness: 1, roughness: 0.06, envMapIntensity: 2.6 });
const tiles = new THREE.InstancedMesh(tileGeometry, tileMaterial, count);
const matrix = new THREE.Matrix4();
const basisX = new THREE.Vector3();
const basisY = new THREE.Vector3();
const quaternion = new THREE.Quaternion();
const scale = new THREE.Vector3();
const worldUp = new THREE.Vector3(0, 1, 0);
placed.forEach((tile, i) => {
// x runs east along the row, y north, z out of the ball.
basisX.crossVectors(worldUp, tile.face).normalize();
basisY.crossVectors(tile.face, basisX);
matrix.makeBasis(basisX, basisY, tile.face);
quaternion.setFromRotationMatrix(matrix);
scale.set(tile.width, rowHeight * 0.92, 0.024);
matrix.compose(tile.normal.clone().multiplyScalar(radius), quaternion, scale);
tiles.setMatrixAt(i, matrix);
tiles.setColorAt(i, new THREE.Color(tile.tone, tile.tone, tile.tone));
});
ball.add(tiles);
// The core, so the gaps between tiles are dark rather than empty.
const core = new THREE.Mesh(new THREE.SphereGeometry(radius * 0.97, 24, 16), new THREE.MeshBasicMaterial({ color: 0x0b0d14 }));
ball.add(core);
// The string.
const string = new THREE.Mesh(new THREE.CylinderGeometry(0.008, 0.008, 3, 6), new THREE.MeshBasicMaterial({ color: 0x8a90a3 }));
string.position.y = radius + 1.5;
object.add(string);
// The flecks: one point per tile per lamp.
const lampCount = LAMPS.length;
const positions = new Float32Array(count * lampCount * 3);
const colorArray = new Float32Array(count * lampCount * 3);
for (let l = 0; l < lampCount; l++) {
const c = new THREE.Color(colors[l] ?? "#ffffff");
for (let i = 0; i < count; i++) {
const k = (l * count + i) * 3;
colorArray[k] = c.r;
colorArray[k + 1] = c.g;
colorArray[k + 2] = c.b;
}
}
const flecksGeometry = new THREE.BufferGeometry();
flecksGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
flecksGeometry.setAttribute("color", new THREE.BufferAttribute(colorArray, 3));
const flecksMaterial = new THREE.PointsMaterial({ size: 0.075, vertexColors: true, transparent: true, opacity: 0.95, blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true });
const flecks = new THREE.Points(flecksGeometry, flecksMaterial);
flecks.frustumCulled = false;
object.add(flecks);
// Coloured light on the tiles themselves.
const lights = LAMPS.map((position, l) => {
const light = new THREE.PointLight(new THREE.Color(colors[l] ?? "#ffffff"), 22, 9, 1.6);
light.position.copy(position);
object.add(light);
return light;
});
const world = new THREE.Vector3();
const incident = new THREE.Vector3();
const reflected = new THREE.Vector3();
const normal = new THREE.Vector3();
const spin = new THREE.Quaternion();
let built = 0;
let speed = 1;
const place = (angle: number, lean: number) => {
ball.rotation.set(lean, angle, 0);
ball.updateMatrixWorld(true);
spin.setFromEuler(ball.rotation);
for (let l = 0; l < lampCount; l++) {
const lamp = LAMPS[l]!;
for (let i = 0; i < count; i++) {
const k = (l * count + i) * 3;
normal.copy(normals[i]!).applyQuaternion(spin);
world.copy(sites[i]!).applyQuaternion(spin).multiplyScalar(radius);
incident.copy(world).sub(lamp).normalize();
// The tile faces the lamp, and its reflection leaves toward the room.
const facing = -incident.dot(normal);
if (facing <= 0.05) {
positions[k] = positions[k + 1] = positions[k + 2] = 9999;
continue;
}
reflected.copy(incident).addScaledVector(normal, -2 * incident.dot(normal));
// Ray from the tile to the sphere of the room (radius ROOM, centred on the ball).
const b = world.dot(reflected);
const c = world.lengthSq() - ROOM * ROOM;
const t = -b + Math.sqrt(Math.max(0, b * b - c));
positions[k] = world.x + reflected.x * t;
positions[k + 1] = world.y + reflected.y * t;
positions[k + 2] = world.z + reflected.z * t;
// Hide flecks that would land between the ball and the viewer.
if ((positions[k + 2] ?? 0) > 1.4) positions[k] = positions[k + 1] = positions[k + 2] = 9999;
}
}
(flecksGeometry.getAttribute("position") as THREE.BufferAttribute).needsUpdate = true;
};
place(0.4, 0.12);
return {
object,
assembleSeconds: 1.4,
holdSeconds: 30,
assemble(progress) {
built = progress;
const a = easeOutCubic(clamp01(progress));
object.scale.setScalar((0.4 + 0.6 * a) * 1.32);
object.position.y = (1 - a) * 1.6;
flecksMaterial.opacity = 0.95 * a;
},
update(frame: SculptureFrame) {
const target = frame.still ? 0 : 1 + 2.4 * frame.hover;
speed += (target - speed) * damp(3, frame.delta);
const angle = frame.still ? 0.4 : frame.time * ((rpm * Math.PI * 2) / 60) * speed;
const lean = 0.12 + (frame.still ? 0 : Math.sin(frame.time * 0.4) * 0.05);
place(angle, lean);
const sway = frame.still ? 0 : frame.pointer.x * frame.hover * 0.25;
object.rotation.z = sway * 0.2;
void built;
},
status() {
return null;
},
dispose() {
tileGeometry.dispose();
tileMaterial.dispose();
flecksGeometry.dispose();
flecksMaterial.dispose();
disposeObject(object);
lights.forEach((light) => light.dispose?.());
},
};
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| colors | [string, string, string] | ["#4aa8ff", "#ff4fb6", "#ffd27a"] | The three lamps' colours. |
| tiles | number | 640 | About how many tiles (120 to 1400). |
| rpm | number | 5 | Turns per minute. |
| label | string | — | Accessible name. |
| className | string | — | Merged with cn(); give it a size and put it on a dark background. |
Accessibility and motion
- Keyboard
- Not focusable; the ball is an illustration.
- Screen readers
- An image named by
label. - Touch
- A finger on it spins the ball faster; scrolling is never blocked.
- Reduced motion
- One composed pose of the ball and its flecks; it does not turn.
Edge cases and limits
- The flecks are additive: on a light background they vanish.
- More than 1000 tiles on a phone costs frame time for little gain.
- It is a centrepiece: one per page.
Related

Brochure
FreeA 3D brochure whose paper bends: click a page, drag a corner or press the arrow keys to turn it.
3D · three.js

Coin
FreeA struck coin with a reeded edge, your mark and words on its face; hold the pointer on it and it turns over.
3D · three.js

Dotted Globe
FreeA globe of dots with pulsing markers and arcs; drag to turn it, choose a place to spin to it.
3D · three.js