Skip to content

FreeIn reviewv1.0.0

Mirror Ball

A spinning mirror ball that throws flecks of coloured light across a dark room, faster when you hover.
npx shadcn@latest add @vibebox/mirror-ball

Controls

5
<MirrorBall />

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

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/mirror-ball
  3. 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 files
components/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

PropTypeDefaultDescription
colors[string, string, string]["#4aa8ff", "#ff4fb6", "#ffd27a"]The three lamps' colours.
tilesnumber640About how many tiles (120 to 1400).
rpmnumber5Turns per minute.
labelstring—Accessible name.
classNamestring—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.
  • An open 3D brochure on a desk with a page turning and its corner lifted

    A 3D brochure whose paper bends: click a page, drag a corner or press the arrow keys to turn it.

    3D · three.js

  • A gold coin with a V in the middle and words around its rim, turning on its edge

    Coin

    Free

    A 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

  • A globe made of blue dots with pulsing gold markers and pink arcs between cities

    A globe of dots with pulsing markers and arcs; drag to turn it, choose a place to spin to it.

    3D · three.js

↑ ↓ to move↵ to openesc to close