Skip to content

FreeIn reviewv1.0.0

Ticket

A 3D paper ticket with a foil spine that leans to the pointer and tears at the stub when you hold over it.
npx shadcn@latest add @vibebox/ticket

Controls

<Ticket />

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

  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/ticket
  3. 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 files
components/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

PropTypeDefaultDescription
titlestring"Your event here"The event's name, large, on one or two lines.
subtitlestring"Sample ticket"A short kicker above the title.
datestring—The date line on the footer.
venuestring—The venue line on the footer.
seatstring—The seat or admission line on the footer.
admitstring"Admit one"The word at the top right, and along the back.
serialstring"No. 000482"The serial printed under the code on the stub.
codestring—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.
labelstring—Accessible name.
classNamestring—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.
  • 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