About
The notched ticket of the studio's TicketBox site, as a stage sculpture: two extruded pieces (the main body and the stub) cut with matching half-circle notches, both faces mapped onto one texture so the artwork runs across the cut. The artwork is painted with the 2D canvas from props: paper with fibres, a sun with rays, a title that shrinks to fit, mono footer lines, and a code drawn from a string. A foil spine uses an iridescent physical material. The stub hangs from a hinge at its top corner; when the pointer is within a short distance of its projected centre (or a touch is on it) it swings out, lifts and turns. Paper punch-outs drift around it as an instanced mesh.
Use it when
- An event, ticketing, festival or venue page wants its centrepiece to be the ticket.
- A confirmation or checkout screen wants a moment of delight.
- A gift voucher or pass wants to feel physical.
Skip it when
- The page already has a 3D centrepiece.
- A page that must work without WebGL: the poster is a flat ticket, but the tear is lost.
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/ticket - 3Use it:
import { Ticket } from "@/components/vibebox/ticket"; <Ticket className="aspect-[16/10] w-full max-w-3xl" title="Plaza Nights" subtitle="Sample ticket" date="Sat 12 Oct" venue="Plaza Hall" code="plaza-nights-482" />
Code
3 filescomponents/vibebox/ticket/index.tsx
/**
* VibeBox Motion · Ticket
* 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 { Ticket, type TicketProps } from "./ticket.client";
export type { TicketOptions } from "./ticket.sculpture";components/vibebox/ticket/ticket.client.tsx
/**
* VibeBox Motion · Ticket
* 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 { TicketOptions } from "./ticket.sculpture";
export type TicketProps = TicketOptions & {
/** Accessible name; by default it names the event. */
label?: string;
className?: string;
};
/* A flat ticket in CSS, until the 3D one is ready and wherever WebGL is not. */
function TicketPoster({ title, subtitle, colors }: Pick<TicketOptions, "title" | "subtitle" | "colors">) {
const paper = colors?.paper ?? "#f4ebdb";
const ink = colors?.ink ?? "#0b0920";
const stub = colors?.stub ?? colors?.accent ?? "#ff5b1f";
return (
<span aria-hidden="true" className="absolute inset-[16%_9%] flex rotate-[-4deg] overflow-hidden rounded-[10px] shadow-[0_24px_50px_-20px_rgb(0_0_0/0.6)]" style={{ background: paper, color: ink }}>
<span className="w-[6%]" style={{ background: ink }} />
<span className="flex flex-1 flex-col justify-between p-[5%]">
<span className="font-mono text-[0.6rem] tracking-[0.2em] uppercase opacity-70">{subtitle ?? "Sample ticket"}</span>
<span className="text-[clamp(1.1rem,3.6vw,2.2rem)] leading-[0.95] font-extrabold tracking-tight uppercase">{title ?? "Your event here"}</span>
</span>
<span className="w-[26%] border-l-2 border-dashed" style={{ background: stub, borderColor: "rgb(0 0 0 / 0.25)" }} />
</span>
);
}
/* Warm, neutral rims: the stage's spectrum lights would tint the paper. */
const PAPER_LIGHTS = { left: "#fff1e0", right: "#ffe2c2", back: "#ffffff" };
export function Ticket({ label, className, ...options }: TicketProps) {
const load = useCallback(() => import("./ticket.sculpture").then((module) => module.createTicket), []);
return (
<SculptureCanvas
slug="ticket"
load={load}
options={options}
label={label ?? `A 3D ticket for ${options.title ?? "your event"}; hold the pointer over the stub to tear it`}
poster={<TicketPoster title={options.title} subtitle={options.subtitle} colors={options.colors} />}
lights={PAPER_LIGHTS}
className={className}
/>
);
}components/vibebox/ticket/ticket.sculpture.ts
/**
* VibeBox Motion · Ticket
* 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, seededRandom } from "@/lib/vibebox/engine/three";
import type { Sculpture, SculptureFrame } from "@/lib/vibebox/engine/three";
/*
* A paper ticket you can play with: thick card with notched edges and a stub, a foil spine that
* catches the light, the whole thing leaning toward the pointer. Hold the pointer over the stub (or
* touch it) and it tears away from its hinge; left alone it teases the tear now and then. Every
* word on it is painted on a canvas from props, in the page's own fonts, so the ticket always
* matches the site around it. Original to VibeBox Motion; the idea (a notched ticket whose stub
* tears) follows the studio's TicketBox site.
*/
export type TicketOptions = {
title?: string;
subtitle?: string;
date?: string;
venue?: string;
admit?: string;
seat?: string;
serial?: string;
/** The text the stub's code is drawn from. */
code?: string;
/** The word on the foil spine. */
foil?: string;
colors?: { paper?: string; ink?: string; accent?: string; stub?: string };
};
const W = 4.2;
const H = 1.8;
const STUB_W = 1.1;
const T = 0.034;
const NOTCH = 0.07;
const CORNER = 0.08;
const GAP = 0.014;
const CUT_X = W / 2 - STUB_W;
const MAIN_RIGHT = CUT_X - GAP / 2;
const STUB_LEFT = CUT_X + GAP / 2;
const FACE = T / 2 + 0.0007;
const PX = 500;
const CW = W * PX;
const CH = H * PX;
const STUB_X = (W - STUB_W) * PX;
function mainShape() {
const s = new THREE.Shape();
const l = -W / 2;
const t = H / 2;
const b = -H / 2;
s.moveTo(l + CORNER, t);
s.lineTo(MAIN_RIGHT - NOTCH, t);
s.absarc(MAIN_RIGHT, t, NOTCH, Math.PI, Math.PI * 1.5, false);
s.lineTo(MAIN_RIGHT, b + NOTCH);
s.absarc(MAIN_RIGHT, b, NOTCH, Math.PI * 0.5, Math.PI, false);
s.lineTo(l + CORNER, b);
s.quadraticCurveTo(l, b, l, b + CORNER);
s.lineTo(l, t - CORNER);
s.quadraticCurveTo(l, t, l + CORNER, t);
return s;
}
function stubShape() {
const s = new THREE.Shape();
const r = W / 2;
const t = H / 2;
const b = -H / 2;
s.moveTo(STUB_LEFT + NOTCH, t);
s.lineTo(r - CORNER, t);
s.quadraticCurveTo(r, t, r, t - CORNER);
s.lineTo(r, b + CORNER);
s.quadraticCurveTo(r, b, r - CORNER, b);
s.lineTo(STUB_LEFT + NOTCH, b);
s.absarc(STUB_LEFT, b, NOTCH, 0, Math.PI * 0.5, false);
s.lineTo(STUB_LEFT, t - NOTCH);
s.absarc(STUB_LEFT, t, NOTCH, Math.PI * 1.5, Math.PI * 2, false);
return s;
}
/** Face geometry whose UVs map the whole ticket onto one texture, so both pieces share the art. */
function faceGeometry(shape: THREE.Shape, mirror: boolean) {
const geometry = new THREE.ShapeGeometry(shape, 24);
const position = geometry.getAttribute("position");
const uv: number[] = [];
for (let i = 0; i < position.count; i++) {
const u = (position.getX(i) + W / 2) / W;
const v = (position.getY(i) + H / 2) / H;
uv.push(mirror ? 1 - u : u, v);
}
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uv, 2));
return geometry;
}
function edgeGeometry(shape: THREE.Shape) {
const geometry = new THREE.ExtrudeGeometry(shape, { depth: T, bevelEnabled: false, curveSegments: 24 });
geometry.translate(0, 0, -T / 2);
return geometry;
}
/* ---- the artwork, painted with the 2D canvas ---- */
type Fonts = { display: string; mono: string; serif: string };
function readFonts(): Fonts {
const style = getComputedStyle(document.body);
const pick = (name: string, fallback: string) => style.getPropertyValue(name).trim() || fallback;
return { display: pick("--font-display", "system-ui, sans-serif"), mono: pick("--font-mono", "ui-monospace, monospace"), serif: pick("--font-serif", "Georgia, serif") };
}
function makeCanvas(width = CW, height = CH) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
return canvas;
}
function tracking(ctx: CanvasRenderingContext2D, px: number) {
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${px}px`;
}
function fibers(ctx: CanvasRenderingContext2D, seed: number, alpha: number) {
const rnd = seededRandom(seed);
ctx.save();
ctx.lineWidth = 1;
for (let i = 0; i < 2200; i++) {
const x = rnd() * CW;
const y = rnd() * CH;
const length = 4 + rnd() * 14;
const angle = rnd() * Math.PI;
ctx.strokeStyle = `rgba(80, 60, 30, ${(rnd() * alpha).toFixed(3)})`;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + Math.cos(angle) * length, y + Math.sin(angle) * length);
ctx.stroke();
}
ctx.restore();
}
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
/** A square code-like matrix from a string: three finder squares and seeded modules. */
function matrix(code: string, size = 25): boolean[][] {
let seed = 0;
for (const char of code) seed = (seed * 31 + char.charCodeAt(0)) >>> 0;
const rnd = seededRandom(seed || 1);
const grid = Array.from({ length: size }, () => Array.from({ length: size }, () => rnd() > 0.52));
const finder = (ox: number, oy: number) => {
for (let y = -1; y <= 7; y++) {
for (let x = -1; x <= 7; x++) {
const row = grid[oy + y];
if (!row || ox + x < 0 || ox + x >= size) continue;
const ring = x === 0 || x === 6 || y === 0 || y === 6;
const core = x >= 2 && x <= 4 && y >= 2 && y <= 4;
row[ox + x] = x >= 0 && x <= 6 && y >= 0 && y <= 6 && (ring || core);
}
}
};
finder(0, 0);
finder(size - 7, 0);
finder(0, size - 7);
return grid;
}
function paintFront(o: Required<Omit<TicketOptions, "colors">> & { colors: Required<NonNullable<TicketOptions["colors"]>> }, fonts: Fonts) {
const canvas = makeCanvas();
const ctx = canvas.getContext("2d")!;
const { paper, ink, accent, stub } = o.colors;
ctx.fillStyle = paper;
ctx.fillRect(0, 0, CW, CH);
fibers(ctx, 7, 0.07);
// The foil spine's dark bed.
ctx.fillStyle = ink;
ctx.fillRect(0, 0, 92, CH);
// A big sun with rays, behind the title.
const sx = STUB_X * 0.66;
const sy = CH * 0.5;
ctx.save();
ctx.beginPath();
ctx.rect(92, 0, STUB_X - 92, CH);
ctx.clip();
ctx.strokeStyle = "rgba(0,0,0,0.12)";
ctx.lineWidth = 3;
for (let i = 0; i < 48; i++) {
const a = (i / 48) * Math.PI * 2;
ctx.beginPath();
ctx.moveTo(sx + Math.cos(a) * 330, sy + Math.sin(a) * 330);
ctx.lineTo(sx + Math.cos(a) * 1100, sy + Math.sin(a) * 1100);
ctx.stroke();
}
ctx.fillStyle = accent;
ctx.beginPath();
ctx.arc(sx, sy, 290, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// Header row.
ctx.fillStyle = ink;
ctx.textBaseline = "alphabetic";
ctx.font = `600 30px ${fonts.mono}`;
tracking(ctx, 7);
ctx.fillText(o.subtitle.toUpperCase(), 140, 100);
ctx.textAlign = "right";
ctx.fillText(o.admit.toUpperCase(), STUB_X - 60, 100);
ctx.textAlign = "left";
// Title: as large as fits, on up to two lines.
const words = o.title.toUpperCase().split(/\s+/);
const lines = words.length > 2 ? [words.slice(0, Math.ceil(words.length / 2)).join(" "), words.slice(Math.ceil(words.length / 2)).join(" ")] : words.length === 2 ? [words[0]!, words[1]!] : [o.title.toUpperCase()];
let size = 250;
const maxWidth = STUB_X - 140 - 70;
const fit = () => {
ctx.font = `800 ${size}px ${fonts.display}`;
return Math.max(...lines.map((line) => ctx.measureText(line).width));
};
tracking(ctx, -2);
while (fit() > maxWidth && size > 90) size -= 6;
ctx.font = `800 ${size}px ${fonts.display}`;
ctx.fillStyle = ink;
const top = lines.length === 1 ? CH * 0.58 : CH * 0.5;
lines.forEach((line, index) => ctx.fillText(line, 138, top + index * size * 0.92));
// Footer row.
ctx.strokeStyle = "rgba(0,0,0,0.4)";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(142, CH - 100);
ctx.lineTo(STUB_X - 60, CH - 100);
ctx.stroke();
ctx.fillStyle = ink;
ctx.font = `500 27px ${fonts.mono}`;
tracking(ctx, 5);
ctx.fillText(o.date.toUpperCase(), 142, CH - 48);
ctx.textAlign = "center";
ctx.fillText(o.venue.toUpperCase(), (142 + STUB_X - 60) / 2 + 30, CH - 48);
ctx.textAlign = "right";
ctx.fillText(o.seat.toUpperCase(), STUB_X - 60, CH - 48);
ctx.textAlign = "left";
// The stub: a field of colour with a code on a white card, and the serial under it.
ctx.fillStyle = stub;
ctx.fillRect(STUB_X, 0, CW - STUB_X, CH);
const cx = (STUB_X + CW) / 2;
ctx.fillStyle = paper;
roundRect(ctx, cx - 170, 130, 340, 340, 24);
ctx.fill();
const grid = matrix(o.code);
const cell = 280 / grid.length;
ctx.fillStyle = ink;
grid.forEach((row, y) => row.forEach((on, x) => on && ctx.fillRect(cx - 140 + x * cell, 160 + y * cell, Math.ceil(cell), Math.ceil(cell))));
ctx.textAlign = "center";
ctx.fillStyle = ink;
ctx.font = `600 34px ${fonts.mono}`;
tracking(ctx, 3);
ctx.fillText(o.serial, cx, 560);
ctx.font = `500 22px ${fonts.mono}`;
tracking(ctx, 5);
ctx.fillText("SCAN AT THE DOOR", cx, 606);
// Perforation dots beside the tear line.
ctx.fillStyle = "rgba(0,0,0,0.3)";
for (let y = 40; y < CH; y += 30) {
ctx.beginPath();
ctx.arc(STUB_X - 14, y, 4.5, 0, Math.PI * 2);
ctx.fill();
}
return canvas;
}
function paintBack(o: { colors: Required<NonNullable<TicketOptions["colors"]>>; admit: string; title: string }, fonts: Fonts) {
const canvas = makeCanvas();
const ctx = canvas.getContext("2d")!;
const { paper, ink, accent, stub } = o.colors;
ctx.fillStyle = paper;
ctx.fillRect(0, 0, CW, CH);
fibers(ctx, 21, 0.07);
const stubPx = STUB_W * PX;
ctx.fillStyle = stub;
ctx.fillRect(0, 0, stubPx, CH);
ctx.save();
ctx.translate(stubPx / 2 + 12, CH / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillStyle = ink;
ctx.textAlign = "center";
ctx.font = `600 30px ${fonts.mono}`;
tracking(ctx, 9);
ctx.fillText(`${o.admit.toUpperCase()} · ${o.admit.toUpperCase()}`, 0, 0);
ctx.restore();
// A seal.
const x = CW * 0.62;
const y = CH / 2;
ctx.fillStyle = accent;
ctx.beginPath();
ctx.arc(x, y, 270, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = paper;
ctx.beginPath();
ctx.arc(x, y, 208, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = ink;
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(x, y, 270, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = ink;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = `800 ${o.title.length > 14 ? 54 : 74}px ${fonts.display}`;
tracking(ctx, 0);
ctx.fillText(o.title.toUpperCase().slice(0, 18), x, y);
ctx.font = `500 26px ${fonts.mono}`;
tracking(ctx, 5);
ctx.fillText("ONE SCAN · ONE PERSON", x, y + 74);
return canvas;
}
function paperBump() {
const canvas = makeCanvas(256, 256);
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "#808080";
ctx.fillRect(0, 0, 256, 256);
const rnd = seededRandom(3);
for (let i = 0; i < 4000; i++) {
const v = 110 + Math.floor(rnd() * 50);
ctx.fillStyle = `rgb(${v},${v},${v})`;
ctx.fillRect(rnd() * 256, rnd() * 256, 1 + rnd() * 2, 1 + rnd() * 2);
}
return canvas;
}
function foilTexture(accent: string, ink: string) {
const canvas = makeCanvas(128, 512);
const ctx = canvas.getContext("2d")!;
const g = ctx.createLinearGradient(0, 0, 128, 512);
g.addColorStop(0, "#fff2c9");
g.addColorStop(0.25, accent);
g.addColorStop(0.5, "#fff");
g.addColorStop(0.75, ink);
g.addColorStop(1, "#ffe9b0");
ctx.fillStyle = g;
ctx.fillRect(0, 0, 128, 512);
return canvas;
}
function texture(canvas: HTMLCanvasElement, color = true) {
const map = new THREE.CanvasTexture(canvas);
if (color) map.colorSpace = THREE.SRGBColorSpace;
map.anisotropy = 8;
map.minFilter = THREE.LinearMipmapLinearFilter;
map.generateMipmaps = true;
return map;
}
const DEFAULTS = {
title: "Your event here",
subtitle: "Sample ticket",
date: "Sat 12 Oct",
venue: "Plaza Hall",
admit: "Admit one",
seat: "GA · Row 3",
serial: "No. 000482",
code: "sample-ticket",
foil: "ADMIT ONE",
};
const COLORS = { paper: "#f4ebdb", ink: "#0b0920", accent: "#ff5b1f", stub: "#ff5b1f" };
export function createTicket(options: TicketOptions = {}): Sculpture {
const o = { ...DEFAULTS, ...options, colors: { ...COLORS, ...options.colors } };
const fonts = readFonts();
const textures = {
front: texture(paintFront(o, fonts)),
back: texture(paintBack(o, fonts)),
bump: texture(paperBump(), false),
foil: texture(foilTexture(o.colors.accent, o.colors.ink)),
};
textures.bump.wrapS = THREE.RepeatWrapping;
textures.bump.wrapT = THREE.RepeatWrapping;
textures.bump.repeat.set(6, 3);
const paperMat = (map: THREE.Texture, side: THREE.Side) =>
new THREE.MeshPhysicalMaterial({ map, side, roughness: 0.8, metalness: 0, sheen: 0.55, sheenRoughness: 0.55, sheenColor: new THREE.Color("#fff3df"), bumpMap: textures.bump, bumpScale: 0.35 });
const frontMat = paperMat(textures.front, THREE.FrontSide);
const backMat = paperMat(textures.back, THREE.BackSide);
const edgeMat = new THREE.MeshStandardMaterial({ color: new THREE.Color(o.colors.paper).multiplyScalar(0.92), roughness: 0.9 });
const foilMat = new THREE.MeshPhysicalMaterial({
map: textures.foil,
metalness: 0.5,
roughness: 0.26,
iridescence: 1,
iridescenceIOR: 1.9,
iridescenceThicknessRange: [180, 820],
clearcoat: 1,
clearcoatRoughness: 0.12,
});
const punchMat = new THREE.MeshStandardMaterial({ roughness: 0.8, side: THREE.DoubleSide });
const geometries: THREE.BufferGeometry[] = [];
const keep = <G extends THREE.BufferGeometry>(g: G): G => {
geometries.push(g);
return g;
};
const piece = (shape: THREE.Shape) => {
const group = new THREE.Group();
const front = new THREE.Mesh(keep(faceGeometry(shape, false)), frontMat);
front.position.z = FACE;
const back = new THREE.Mesh(keep(faceGeometry(shape, true)), backMat);
back.position.z = -FACE;
group.add(front, back, new THREE.Mesh(keep(edgeGeometry(shape)), edgeMat));
return group;
};
const root = new THREE.Group();
const ticket = new THREE.Group();
root.add(ticket);
const mainGroup = new THREE.Group();
mainGroup.add(piece(mainShape()));
const foil = new THREE.Mesh(keep(new THREE.PlaneGeometry(0.17, H - 0.26)), foilMat);
foil.position.set(-W / 2 + 0.115, 0, FACE + 0.0012);
mainGroup.add(foil);
ticket.add(mainGroup);
const hinge = new THREE.Group();
hinge.position.set(STUB_LEFT, H / 2, 0);
const stubPiece = piece(stubShape());
stubPiece.position.set(-STUB_LEFT, -H / 2, 0);
hinge.add(stubPiece);
ticket.add(hinge);
// Paper punch-outs drifting around the ticket.
const punchCount = 48;
const punches = new THREE.InstancedMesh(keep(new THREE.CircleGeometry(0.045, 20)), punchMat, punchCount);
const palette = [o.colors.paper, o.colors.accent, o.colors.paper, o.colors.ink, o.colors.paper].map((hex) => new THREE.Color(hex));
const rnd = seededRandom(77);
const punchData = Array.from({ length: punchCount }, (_, i) => {
punches.setColorAt(i, palette[i % palette.length]!);
return { x: (rnd() - 0.5) * 8.5, y: (rnd() - 0.5) * 4.6, z: -2.2 + rnd() * 3.6, phase: rnd() * 6.28, speed: 0.25 + rnd() * 0.5, spin: (rnd() - 0.5) * 1.6, size: 0.6 + rnd() * 1.1 };
});
root.add(punches);
const object = new THREE.Group();
object.add(root);
object.scale.setScalar(1.1);
const temp = new THREE.Object3D();
const stubWorld = new THREE.Vector3();
let tear = 0;
let bob = 0;
let built = 0;
return {
object,
assembleSeconds: 1.7,
holdSeconds: 30,
assemble(progress) {
built = progress;
},
update(frame: SculptureFrame) {
const { time, pointer, hover, delta, still } = frame;
const a = easeOutCubic(clamp01(built));
// Pieces fly in as it builds.
mainGroup.position.set(-(1 - a) * 1.6, (1 - a) * 0.3, -(1 - a) * 1.2);
mainGroup.rotation.set(0, (1 - a) * -0.7, (1 - a) * 0.2);
// Is the pointer over the stub?
stubWorld.set(STUB_LEFT + STUB_W / 2, 0, 0);
ticket.localToWorld(stubWorld);
stubWorld.project(frame.camera);
const overStub = hover > 0.25 && Math.hypot(stubWorld.x - pointer.x, stubWorld.y - pointer.y) < 0.22;
const cycle = (time % 7) / 7;
const tease = !still && hover < 0.05 && cycle > 0.62 && cycle < 0.86 ? Math.sin(((cycle - 0.62) / 0.24) * Math.PI) * 0.7 : 0;
tear += ((overStub ? 1 : tease) - tear) * damp(7, delta);
bob = still ? 0 : Math.sin(time * 0.9) * 0.05;
const px = still ? 0 : pointer.x * hover;
const py = still ? 0 : pointer.y * hover;
root.position.set(-0.18, bob, 0);
root.rotation.set(0.14 - py * 0.2 + (still ? 0 : Math.cos(time * 0.4) * 0.025), -0.3 + px * 0.32 + (still ? 0 : Math.sin(time * 0.5) * 0.05), 0.04);
const t = easeOutCubic(tear);
hinge.position.set(STUB_LEFT + (1 - a) * 1.8 + t * 0.14, H / 2 + (1 - a) * 0.5 - t * 0.04, (1 - a) * -0.4 + t * 0.2);
hinge.rotation.set(t * 0.1, -t * 0.42, -t * 0.34 + (1 - a) * 0.5);
punchData.forEach((p, i) => {
const drift = still ? 0 : Math.sin(time * p.speed + p.phase);
temp.position.set(p.x + (still ? 0 : Math.cos(time * 0.2 + p.phase) * 0.2), p.y + drift * 0.25, p.z);
temp.rotation.set(time * p.spin * 0.4 + p.phase, time * p.spin * 0.3, time * p.spin);
temp.scale.setScalar(p.size * a);
temp.updateMatrix();
punches.setMatrixAt(i, temp.matrix);
});
punches.instanceMatrix.needsUpdate = true;
if (punches.instanceColor) punches.instanceColor.needsUpdate = true;
},
status() {
return tear > 0.6 ? "tearing" : null;
},
dispose() {
geometries.forEach((geometry) => geometry.dispose());
[frontMat, backMat, edgeMat, foilMat, punchMat].forEach((material) => material.dispose());
Object.values(textures).forEach((t) => t.dispose());
punches.dispose();
disposeObject(object);
},
};
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | "Your event here" | The event's name, large, on one or two lines. |
| subtitle | string | "Sample ticket" | A short kicker above the title. |
| date | string | — | The date line on the footer. |
| venue | string | — | The venue line on the footer. |
| seat | string | — | The seat or admission line on the footer. |
| admit | string | "Admit one" | The word at the top right, and along the back. |
| serial | string | "No. 000482" | The serial printed under the code on the stub. |
| code | string | — | Any text; the stub's code pattern is drawn from it, so the same text always draws the same code. |
| colors | { paper?: string; ink?: string; accent?: string; stub?: string } | — | Paper, ink, the sun and the stub field. |
| label | string | — | Accessible name. |
| className | string | — | Merged with cn(); give it a size and aspect ratio (about 16 / 10). |
Accessibility and motion
- Keyboard
- Not focusable; the ticket is an illustration. Put the real ticket actions next to it.
- Screen readers
- An image named by
label. - Touch
- Touching the stub tears it; releasing lets it settle back. Scrolling is never blocked.
- Reduced motion
- One composed pose of the ticket with the stub in place; no lean, tease or drift.
Edge cases and limits
- Very long titles shrink to fit; keep them under 24 characters for a readable ticket.
- The code is a decorative pattern, not a scannable barcode: never use it as a real ticket's code.
- It loads three.js (about 170 KB gzip) when it nears the viewport; the rest of the page is unaffected.
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