3D & Experimental
HologramStage
A small theatre stage where a floor projector throws a turning wire hologram into the dark: a temple tower, a lotus or a lamp. On a change the lines fly apart and rebuild as the next model. Drag turns it. WebGL, no three.js.
Download
- React
- Tailwind CSS v4
- motion
components/ui/hologram-stage.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
type HologramStageProps = {
/** Six-digit hex colour of the hologram. */
color?: string;
/** Change the model by itself until a visitor picks one. */
auto?: boolean;
/** Seconds on each model in auto mode. */
hold?: number;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
/** Name read out by screen readers. */
label?: string;
/** Set the size here. */
className?: string;
};
const MAX_PIXELS = 1200000;
const FOCAL = 2.7;
const MODELS = ["Gopuram", "Lotus", "Lamp"];
const WASH_VERTEX = `attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}`;
const WASH_FRAGMENT = `precision highp float;
uniform vec2 res;
uniform vec3 beam,cam,rt,upv,fwd;
uniform float time,power;
const float R=.62,H=.16;
float hash(float n){return fract(sin(n)*43758.5453);}
vec3 cone(vec3 o,vec3 d){
float down=dot(d.xz,d.xz);
float s=max(-dot(o.xz,d.xz)/max(down,1e-4),0.);
vec3 p=o+d*s;
float reach=mix(.4,1.14,clamp((p.y-H)/2.2,0.,1.));
float across=length(p.xz)/reach;
float a=smoothstep(1.,.25,across)*smoothstep(H,H+.12,p.y)*smoothstep(2.75,1.2,p.y);
a*=.82+.18*sin(p.y*9.-time*1.3+sin(p.x*7.)*1.4);
return beam*a*.2*power;
}
void main(){
vec2 uv=(gl_FragCoord.xy-.5*res)/(.5*min(res.x,res.y));
vec3 d=normalize(uv.x*rt+uv.y*upv+2.7*fwd);
vec3 c=vec3(0.);
float t=1e5;
float a=dot(d.xz,d.xz),b=dot(cam.xz,d.xz),k=dot(cam.xz,cam.xz)-R*R,disc=b*b-a*k;
bool done=false;
if(d.y<0.){
float top=(H-cam.y)/d.y;
vec3 p=cam+d*top;
float r=length(p.xz);
if(r<R){
float ang=atan(p.z,p.x);
float stud=step(r,.56)*step(.5,r)*smoothstep(.03,.0,abs(fract(ang*1.91)-.5)/1.91*r*6.28-.0);
c=vec3(.05,.04,.035)+beam*smoothstep(.42,.12,r)*1.5*power+beam*smoothstep(.45,.43,r)*.18+beam*stud*1.2*power;
done=true;
}
}
if(!done&&disc>0.){
float s=(-b-sqrt(disc))/a;
vec3 p=cam+d*s;
if(s>0.&&p.y>0.&&p.y<H){
vec3 n=normalize(vec3(p.x,0.,p.z));
float rib=.6+.4*sin(atan(p.z,p.x)*48.);
c=vec3(.21,.15,.08)*(.25+.75*max(dot(n,normalize(vec3(-.5,.3,.8))),0.))*rib;
c+=beam*smoothstep(.1,.16,p.y)*.14;
done=true;
}
}
if(!done){
float back=(-2.7-cam.z)/d.z;
float fl=d.y<0.?-cam.y/d.y:1e5;
if(fl<back){
vec3 p=cam+d*fl;
float board=floor(p.x*2.6);
float grain=.7+.3*sin(p.z*3.+hash(board)*40.)*sin(p.z*17.+board);
c=vec3(.085,.05,.03)*(.55+.45*hash(board+7.))*grain;
c*=smoothstep(.0,.03,fract(p.x*2.6));
float gap=length(p.xz);
c+=c*beam*5.*power/(1.+gap*gap*2.2);
c+=beam*.11*power/(1.+gap*gap*5.);
vec3 flip=vec3(d.x,-d.y,d.z);
c+=cone(p,flip)*.55;
}else{
vec3 p=cam+d*back;
float fold=.5+.5*sin(p.x*8.+sin(p.x*2.7)*1.7);
float lit=exp(-p.x*p.x*.32)*exp(-pow(p.y-1.,2.)*.22);
c=vec3(.2,.035,.045)*(.18+.82*fold*fold)*(.1+1.1*lit*power);
c+=beam*fold*lit*.035*power;
}
}
c+=cone(cam,d);
c=1.-exp(-c*1.5);
gl_FragColor=vec4(pow(c,vec3(.78)),1.);
}`;
const LINE_VERTEX = `attribute vec3 a,b;
attribute float seed;
uniform vec2 fit,nudge;
uniform vec3 cam,rt,upv,fwd;
uniform float yaw,blend,time,tear,mirror;
varying float fade,lift;
void main(){
float m=clamp(blend*1.7-seed*.7,0.,1.);
m=m*m*(3.-2.*m);
vec3 p=mix(a,b,m);
float puff=sin(m*3.14159);
p.xz*=1.+puff*(.2+seed*.5);
p.y+=puff*(seed-.5)*.5;
lift=p.y;
p*=.84;
float c=cos(yaw),s=sin(yaw);
p=vec3(p.x*c+p.z*s,p.y,-p.x*s+p.z*c);
p.x+=tear*sin(floor(p.y*22.)*91.7+time*43.)*.1;
fade=.5+.5*smoothstep(-.9,.9,p.z);
p.y+=.3;
p.y*=mirror;
vec3 q=p-cam;
float z=dot(q,fwd);
gl_Position=vec4(dot(q,rt)*${FOCAL.toFixed(1)}*fit.x+nudge.x*z,dot(q,upv)*${FOCAL.toFixed(1)}*fit.y+nudge.y*z,0.,z);
}`;
const LINE_FRAGMENT = `precision highp float;
uniform vec3 beam;
uniform float time,mirror,power;
varying float fade,lift;
void main(){
float scan=.7+.3*sin(gl_FragCoord.y*1.3-time*6.);
float x=fract(lift*.4-time*.16)-.5;
float band=exp(-x*x*120.)*1.1;
gl_FragColor=vec4(beam*fade*scan*(.85+band)*power*(mirror<0.?.1:.42),0.);
}`;
type Add = (x1: number, y1: number, z1: number, x2: number, y2: number, z2: number) => void;
function collector() {
const lines: number[] = [];
const add: Add = (...v) => lines.push(...v);
return { lines, add };
}
function ring(add: Add, y: number, rx: number, rz: number, parts: number) {
for (let i = 0; i < parts; i++) {
const a = (i / parts) * Math.PI * 2;
const b = ((i + 1) / parts) * Math.PI * 2;
add(Math.cos(a) * rx, y, Math.sin(a) * rz, Math.cos(b) * rx, y, Math.sin(b) * rz);
}
}
function square(add: Add, y: number, w: number, d: number) {
add(-w, y, -d, w, y, -d);
add(w, y, -d, w, y, d);
add(w, y, d, -w, y, d);
add(-w, y, d, -w, y, -d);
}
/* Spins a side outline round the centre, with rings and long lines. */
function lathe(add: Add, shape: number[][], spokes: number, parts: number) {
shape.forEach(([r, y]) => ring(add, y, r, r, parts));
for (let s = 0; s < spokes; s++) {
const a = (s / spokes) * Math.PI * 2;
for (let i = 0; i + 1 < shape.length; i++) {
add(Math.cos(a) * shape[i][0], shape[i][1], Math.sin(a) * shape[i][0], Math.cos(a) * shape[i + 1][0], shape[i + 1][1], Math.sin(a) * shape[i + 1][0]);
}
}
}
/* A temple tower: tiers of pillars that step inward, a barrel roof and a row of pots on top. */
function gopuram() {
const { lines, add } = collector();
square(add, 0, 0.82, 0.56);
square(add, 0.3, 0.82, 0.56);
for (let i = -6; i <= 6; i++) {
if (Math.abs(i) > 1) {
add((i * 0.82) / 6, 0, 0.56, (i * 0.82) / 6, 0.3, 0.56);
add((i * 0.82) / 6, 0, -0.56, (i * 0.82) / 6, 0.3, -0.56);
}
}
for (let i = 0; i < 12; i++) {
const a = (i / 12) * Math.PI;
const b = ((i + 1) / 12) * Math.PI;
add(Math.cos(a) * 0.2, 0.18 + Math.sin(a) * 0.1, 0.56, Math.cos(b) * 0.2, 0.18 + Math.sin(b) * 0.1, 0.56);
}
add(-0.2, 0, 0.56, -0.2, 0.18, 0.56);
add(0.2, 0, 0.56, 0.2, 0.18, 0.56);
const tiers = 7;
for (let t = 0; t < tiers; t++) {
const k = 1 - t * 0.105;
const w = 0.76 * k;
const d = 0.5 * k;
const y0 = 0.3 + t * 0.2;
const y1 = y0 + 0.2;
square(add, y0 + 0.012, w * 1.05, d * 1.05);
square(add, y0 + 0.03, w, d);
square(add, y1, w * 1.03, d * 1.03);
const posts = 10 - t;
for (let i = 0; i <= posts; i++) {
const x = -w + (2 * w * i) / posts;
const top = y0 + (i % 2 ? 0.2 : 0.14);
add(x, y0 + 0.03, d, x, top, d);
add(x, y0 + 0.03, -d, x, top, -d);
if (i % 2) square2(add, x, y0 + 0.15, d);
}
for (let i = 0; i <= 4; i++) {
const z = -d + (2 * d * i) / 4;
add(w, y0 + 0.03, z, w, y1, z);
add(-w, y0 + 0.03, z, -w, y1, z);
}
}
const top = 0.3 + tiers * 0.2;
const w = 0.34;
for (let s = 0; s <= 7; s++) {
const x = -w + (2 * w * s) / 7;
for (let i = 0; i < 12; i++) {
const a = (i / 12) * Math.PI;
const b = ((i + 1) / 12) * Math.PI;
add(x, top + Math.sin(a) * 0.2, Math.cos(a) * 0.17, x, top + Math.sin(b) * 0.2, Math.cos(b) * 0.17);
if (s < 7) add(x, top + Math.sin(a) * 0.2, Math.cos(a) * 0.17, x + (2 * w) / 7, top + Math.sin(a) * 0.2, Math.cos(a) * 0.17);
}
}
for (let i = -2; i <= 2; i++) {
const shift = lines.length;
lathe(add, [[0.015, 0], [0.04, 0.03], [0.018, 0.06], [0.005, 0.11]], 4, 8);
for (let n = shift; n < lines.length; n += 3) {
lines[n] += i * 0.15;
lines[n + 1] += top + 0.2;
}
}
return lines;
}
function square2(add: Add, x: number, y: number, z: number) {
const s = 0.03;
add(x - s, y, z, x, y + s, z);
add(x, y + s, z, x + s, y, z);
}
/* A lotus: three rings of ribbed petals round a seed head. */
function lotus() {
const { lines, add } = collector();
const petal = (turn: number, length: number, rise: number, wide: number, base: number) => {
const at = (u: number, side: number) => {
const r = 0.12 + length * u;
const y = base + rise * Math.pow(u, 1.6);
const half = wide * Math.sin(Math.PI * Math.pow(u, 0.72)) * side;
return [Math.cos(turn) * r - Math.sin(turn) * half, y, Math.sin(turn) * r + Math.cos(turn) * half];
};
const sides = [-1, -0.5, 0, 0.5, 1];
for (let i = 0; i < 12; i++) {
for (const side of sides) add(...(at(i / 12, side).concat(at((i + 1) / 12, side)) as [number, number, number, number, number, number]));
if (i % 3 === 2) {
for (let s = 0; s + 1 < sides.length; s++) add(...(at(i / 12, sides[s]).concat(at(i / 12, sides[s + 1])) as [number, number, number, number, number, number]));
}
}
};
for (let i = 0; i < 11; i++) petal((i / 11) * Math.PI * 2, 0.92, 0.42, 0.25, 0.42);
for (let i = 0; i < 9; i++) petal(((i + 0.5) / 9) * Math.PI * 2, 0.62, 0.86, 0.22, 0.46);
for (let i = 0; i < 6; i++) petal((i / 6) * Math.PI * 2, 0.32, 1, 0.16, 0.5);
lathe(add, [[0.12, 0.5], [0.15, 0.6], [0.1, 0.66]], 10, 16);
ring(add, 0.42, 0.95, 0.95, 44);
return lines;
}
/* A standing brass temple lamp with five wicks. */
function templeLamp() {
const { lines, add } = collector();
lathe(add, [
[0.56, 0], [0.56, 0.05], [0.42, 0.1], [0.14, 0.16], [0.07, 0.24], [0.1, 0.28], [0.06, 0.33], [0.05, 0.7], [0.11, 0.76], [0.05, 0.82],
[0.06, 1.12], [0.22, 1.16], [0.5, 1.22], [0.6, 1.3], [0.56, 1.33], [0.2, 1.3], [0.06, 1.34], [0.05, 1.56], [0.1, 1.61], [0.05, 1.66],
[0.12, 1.72], [0.05, 1.8], [0.08, 1.86], [0.01, 1.98],
], 16, 28);
for (let s = 0; s < 5; s++) {
const a = (s / 5) * Math.PI * 2;
const x = Math.cos(a) * 0.63;
const z = Math.sin(a) * 0.63;
for (let i = 0; i < 10; i++) {
const t0 = (i / 10) * Math.PI * 2;
const t1 = ((i + 1) / 10) * Math.PI * 2;
const shape = (t: number) => 0.055 * Math.sin(t) * (1 - 0.6 * (0.5 - 0.5 * Math.cos(t)));
add(x + shape(t0), 1.46 - Math.cos(t0) * 0.1, z, x + shape(t1), 1.46 - Math.cos(t1) * 0.1, z);
add(x, 1.46 - Math.cos(t0) * 0.1, z + shape(t0), x, 1.46 - Math.cos(t1) * 0.1, z + shape(t1));
}
}
return lines;
}
/* All models get the same length so any one can fly into any other; short ones are filled with zero-length lines. */
function buildModels() {
const raw = [gopuram(), lotus(), templeLamp()];
const size = Math.max(...raw.map((m) => m.length));
const scatter = (i: number, parts: number) => Math.floor(((i * 2654435761) >>> 0) / 4294967296 * parts);
const models = raw.map((m) => {
const out = new Float32Array(size);
const parts = m.length / 6;
const order = Array.from({ length: parts }, (_, i) => i);
for (let i = parts - 1; i > 0; i--) {
const j = scatter(i + 11, i + 1);
[order[i], order[j]] = [order[j], order[i]];
}
for (let i = 0; i < size / 6; i++) {
const from = order[i % parts] * 6;
for (let c = 0; c < 6; c++) out[i * 6 + c] = i < parts ? m[from + c] : m[from + (c % 3)];
}
return out;
});
const seeds = new Float32Array(size / 3);
for (let i = 0; i < size / 6; i++) seeds[i * 2] = seeds[i * 2 + 1] = scatter(i + 3, 1000) / 1000;
return { models, seeds, count: size / 3 };
}
const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
function program(gl: WebGLRenderingContext, vertex: string, fragment: string) {
const made = gl.createProgram();
for (const [type, text] of [[gl.VERTEX_SHADER, vertex], [gl.FRAGMENT_SHADER, fragment]] as const) {
const shader = gl.createShader(type);
if (!made || !shader) return null;
gl.shaderSource(shader, text);
gl.compileShader(shader);
gl.attachShader(made, shader);
}
if (!made) return null;
gl.linkProgram(made);
return gl.getProgramParameter(made, gl.LINK_STATUS) ? made : null;
}
/* HologramStage: a small theatre stage where a floor projector throws a turning wire hologram into the dark: a temple tower, a lotus or a lamp. Lines fly apart and rebuild on a change. Drag turns it. Runs only while on screen. */
export function HologramStage({
color = "#ffb347",
auto = true,
hold = 7,
speed = 1,
label = "A wire hologram on a small stage",
className = "aspect-[4/3] w-[28rem]",
}: HologramStageProps) {
const reduce = useReducedMotion();
const boxRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const plainRef = useRef<HTMLDivElement>(null);
const pickRef = useRef<(index: number, byHand: boolean) => void>(() => {});
const [active, setActive] = useState(0);
useEffect(() => {
const box = boxRef.current;
const stage = stageRef.current;
const plain = plainRef.current;
if (!box || !stage || !plain) return;
const canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%";
const gl = canvas.getContext("webgl", { alpha: false, antialias: false });
const wash = gl && program(gl, WASH_VERTEX, WASH_FRAGMENT);
const wire = gl && program(gl, LINE_VERTEX, LINE_FRAGMENT);
if (!gl || !wash || !wire) return;
const corners = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, corners);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const corner = gl.getAttribLocation(wash, "p");
const { models, seeds, count } = buildModels();
const fromBuffer = gl.createBuffer();
const toBuffer = gl.createBuffer();
const seedBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, seedBuffer);
gl.bufferData(gl.ARRAY_BUFFER, seeds, gl.STATIC_DRAW);
const slotA = gl.getAttribLocation(wire, "a");
const slotB = gl.getAttribLocation(wire, "b");
const slotSeed = gl.getAttribLocation(wire, "seed");
let current = 0;
const load = (buffer: WebGLBuffer | null, index: number) => {
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, models[index], gl.DYNAMIC_DRAW);
};
load(fromBuffer, 0);
load(toBuffer, 0);
const cam = [0, 1.42, 5.6];
const target = [0, 0.98, 0];
const fwd = cam.map((v, i) => target[i] - v);
const size = Math.hypot(...fwd);
fwd.forEach((v, i) => (fwd[i] = v / size));
const rt = [1, 0, 0];
const upv = [0, -fwd[2], fwd[1]];
const shared = (made: WebGLProgram) => {
gl.useProgram(made);
gl.uniform3fv(gl.getUniformLocation(made, "cam"), cam);
gl.uniform3fv(gl.getUniformLocation(made, "rt"), rt);
gl.uniform3fv(gl.getUniformLocation(made, "upv"), upv);
gl.uniform3fv(gl.getUniformLocation(made, "fwd"), fwd);
gl.uniform3fv(gl.getUniformLocation(made, "beam"), rgb(color));
};
shared(wash);
shared(wire);
const washAt = (name: string) => gl.getUniformLocation(wash, name);
const wireAt = (name: string) => gl.getUniformLocation(wire, name);
let raf = 0;
let last = 0;
let inView = false;
let time = 0;
let yaw = 0.55;
let spin = 0.3;
let blend = 1;
let tear = 0;
let waited = 0;
let keepAuto = auto;
let held = false;
let lastX = 0;
let width = 1;
let height = 1;
const lines = (mirror: number, x: number, y: number) => {
gl.uniform1f(wireAt("mirror"), mirror);
gl.uniform2f(wireAt("nudge"), x, y);
gl.drawArrays(gl.LINES, 0, count);
};
const draw = () => {
const power = 0.92 + 0.05 * Math.sin(time * 31) * Math.sin(time * 7.3) + tear * 0.25;
gl.disable(gl.BLEND);
gl.useProgram(wash);
gl.disableVertexAttribArray(slotA);
gl.disableVertexAttribArray(slotB);
gl.disableVertexAttribArray(slotSeed);
gl.bindBuffer(gl.ARRAY_BUFFER, corners);
gl.enableVertexAttribArray(corner);
gl.vertexAttribPointer(corner, 2, gl.FLOAT, false, 0, 0);
gl.uniform2f(washAt("res"), width, height);
gl.uniform1f(washAt("time"), time % 1000);
gl.uniform1f(washAt("power"), power);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.disableVertexAttribArray(corner);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE);
gl.useProgram(wire);
gl.bindBuffer(gl.ARRAY_BUFFER, fromBuffer);
gl.enableVertexAttribArray(slotA);
gl.vertexAttribPointer(slotA, 3, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, toBuffer);
gl.enableVertexAttribArray(slotB);
gl.vertexAttribPointer(slotB, 3, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, seedBuffer);
gl.enableVertexAttribArray(slotSeed);
gl.vertexAttribPointer(slotSeed, 1, gl.FLOAT, false, 0, 0);
const side = Math.min(width, height);
gl.uniform2f(wireAt("fit"), side / width, side / height);
gl.uniform1f(wireAt("yaw"), yaw);
gl.uniform1f(wireAt("blend"), blend);
gl.uniform1f(wireAt("time"), time % 1000);
gl.uniform1f(wireAt("tear"), tear);
gl.uniform1f(wireAt("power"), power);
const px = 2 / width;
lines(1, 0, 0);
lines(1, px, 0);
lines(-1, 0, 0);
};
const pick = (index: number, byHand: boolean) => {
if (byHand) keepAuto = false;
if (index === current) return;
load(fromBuffer, current);
load(toBuffer, index);
current = index;
blend = reduce ? 1 : 0;
tear = reduce ? 0 : 1;
waited = 0;
setActive(index);
if (reduce) draw();
};
pickRef.current = pick;
const frame = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000 || 0);
last = now;
time += dt;
if (!held) {
yaw += spin * dt * speed;
spin += (0.3 - spin) * Math.min(1, dt * 2);
}
blend = Math.min(1, blend + dt / 1.5);
tear = Math.max(0, tear - dt * 1.7);
if (keepAuto && blend === 1 && !held) {
waited += dt;
if (waited >= hold) pick((current + 1) % MODELS.length, false);
}
draw();
raf = inView ? requestAnimationFrame(frame) : 0;
};
const start = () => {
if (inView && !raf && !reduce) raf = requestAnimationFrame(frame);
};
const fit = () => {
const r = stage.getBoundingClientRect();
if (!r.width || !r.height) return;
const scale = Math.min(Math.max(window.devicePixelRatio, 1.5), 2, Math.sqrt(MAX_PIXELS / (r.width * r.height)));
width = canvas.width = Math.round(r.width * scale);
height = canvas.height = Math.round(r.height * scale);
gl.viewport(0, 0, width, height);
draw();
};
const press = (e: PointerEvent) => {
held = true;
lastX = e.clientX;
stage.setPointerCapture(e.pointerId);
};
const drag = (e: PointerEvent) => {
if (!held) return;
const dx = e.clientX - lastX;
lastX = e.clientX;
yaw += dx * 0.01;
spin = dx * 0.5;
if (reduce) draw();
};
const release = () => {
held = false;
};
fit();
stage.append(canvas);
plain.hidden = true;
const sized = new ResizeObserver(fit);
sized.observe(stage);
const watch = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
start();
});
watch.observe(stage);
const lost = () => {
canvas.remove();
plain.hidden = false;
};
canvas.addEventListener("webglcontextlost", lost);
stage.addEventListener("pointerdown", press);
stage.addEventListener("pointermove", drag);
stage.addEventListener("pointerup", release);
stage.addEventListener("pointercancel", release);
return () => {
pickRef.current = () => {};
cancelAnimationFrame(raf);
sized.disconnect();
watch.disconnect();
canvas.removeEventListener("webglcontextlost", lost);
stage.removeEventListener("pointerdown", press);
stage.removeEventListener("pointermove", drag);
stage.removeEventListener("pointerup", release);
stage.removeEventListener("pointercancel", release);
gl.getExtension("WEBGL_lose_context")?.loseContext();
canvas.remove();
plain.hidden = false;
};
}, [color, auto, hold, speed, reduce]);
return (
<div ref={boxRef} role="group" aria-label={label} className={`relative overflow-hidden bg-[#0a0605] [container-type:inline-size] ${className}`}>
<div ref={stageRef} aria-hidden="true" className="absolute inset-0 cursor-grab touch-pan-y active:cursor-grabbing">
<div
ref={plainRef}
className="absolute bottom-[22%] left-1/2 h-1/2 w-[28%] -translate-x-1/2"
style={{ background: `linear-gradient(to top, ${color}, transparent)`, clipPath: "polygon(30% 100%, 70% 100%, 100% 0, 0 0)" }}
/>
</div>
<div className="absolute inset-x-0 bottom-[3cqw] flex justify-center gap-[1.5cqw]">
{MODELS.map((name, i) => (
<button
key={name}
type="button"
aria-pressed={active === i}
onClick={() => pickRef.current(i, true)}
className="cursor-pointer rounded-[1cqw] border px-[2cqw] py-[1cqw] font-mono uppercase leading-none tracking-wider transition-colors"
style={{
fontSize: "clamp(10px, 2.3cqw, 13px)",
borderColor: active === i ? color : `${color}44`,
color: active === i ? "#14090a" : color,
background: active === i ? color : "#0a060599",
}}
>
{name}
</button>
))}
</div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/hologram-stage.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/hologram-stage-demo.tsx
import { HologramStage } from "@/components/ui/hologram-stage"; export default function HologramStageExample() { return <HologramStage className="size-full min-h-[260px]" />; }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| color | string | "#ffb347" | Six-digit hex colour of the hologram. |
| auto | boolean | true | Change the model by itself until a visitor picks one. |
| hold | number | 7 | Seconds on each model in auto mode. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| label | string | "A wire hologram on a small stage" | Name read out by screen readers. |
| className | string | "aspect-[4/3] w-[28rem]" | Set the size here. |