Boarding Pass
NewInstallation
npx shadcn@latest add https://uibrik.com/r/boarding-pass.jsonInstall the packages
npm install @tabler/icons-react motionAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/boarding-pass/boarding-pass.tsx"use client"; import { useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { motion, useReducedMotion } from "motion/react"; import { IconCheck, IconPlane } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { bodyEdgeMask, EDGE_MAX, paintStub, PERF, runTear, supportsWebGL2, tearEdge, type TearControl } from "./tear-gl"; // Boarding pass. A ticket with a perforated stub. Pull the stub away and it // tears along the perforation one paper bridge at a time, only as far as the // pull goes: the paper above the tear swings away round it while the paper // below stays held, and each bridge holds a moment before it gives. Let go // and the torn part stays torn; pull again to carry on. When the last bridges // give, the stub tumbles away and the ticket is left with a fibrous edge and // a "Checked in" mark. Enter tears it straight through. The stub is a paper // mesh in WebGL (see tear-gl.ts); without WebGL, or with reduced motion, a // press tips it off and fades it instead. // // The notches and the torn edge are CSS masks, so the outline follows them via // drop-shadow on the wrapper rather than a border. const NOTCH = 10; // radius of the half-circle bites at the perforation, px const STUB_W = 112; // px const BODY_W = 340; // px const HEIGHT = 184; // px const RADIUS = 20; // px // Room around the stub for the tumble, px. const ROOM = { left: 12, top: 48, right: 260, bottom: 300 }; const noSubscribe = () => () => {}; type Layer = { image: string; size?: string; repeat?: string }; const bites = (side: "left" | "right"): Layer[] => ["0", "100%"].map((y) => ({ image: `radial-gradient(circle ${NOTCH}px at ${side === "right" ? "100%" : "0"} ${y}, #0000 ${NOTCH - 0.5}px, #000 ${NOTCH}px)`, })); // Every layer has to be opaque for a pixel to show, so the bites and the teeth stack. const maskStyle = (layers: Layer[]) => { const image = layers.map((l) => l.image).join(","); const size = layers.map((l) => l.size ?? "100% 100%").join(","); const repeat = layers.map((l) => l.repeat ?? "no-repeat").join(","); return { maskImage: image, maskSize: size, maskRepeat: repeat, maskComposite: "intersect", WebkitMaskImage: image, WebkitMaskSize: size, WebkitMaskRepeat: repeat, WebkitMaskComposite: "source-in", }; }; // A stand-in barcode: finder squares in three corners, the rest keyed off the text. function Code({ seed }: { seed: string }) { const cells = useMemo(() => { let h = 2166136261; for (const c of seed) h = Math.imul(h ^ c.charCodeAt(0), 16777619); const out: [number, number][] = []; for (let y = 0; y < 9; y++) for (let x = 0; x < 9; x++) { const finder = (x < 3 && y < 3) || (x > 5 && y < 3) || (x < 3 && y > 5); if (finder) continue; h = Math.imul(h ^ (h >>> 15), 2246822507) + x * 31 + y; if ((h >>> 7) & 1) out.push([x, y]); } return out; }, [seed]); return ( <svg viewBox="0 0 9 9" className="size-16" fill="currentColor" shapeRendering="crispEdges" aria-hidden data-tear-svg> {[[0, 0], [6, 0], [0, 6]].map(([x, y]) => ( <g key={`${x}${y}`}> <path d={`M${x} ${y}h3v3h-3z M${x + 0.6} ${y + 0.6}v1.8h1.8v-1.8z`} fillRule="evenodd" /> <rect x={x + 1} y={y + 1} width="1" height="1" /> </g> ))} {cells.map(([x, y]) => ( <rect key={`${x}-${y}`} x={x} y={y} width="1" height="1" /> ))} </svg> ); } export type BoardingPassProps = { /** Departure airport code. */ from?: string; /** Arrival airport code. */ to?: string; fromCity?: string; toCity?: string; passenger?: string; flight?: string; date?: string; gate?: string; seat?: string; boarding?: string; /** Torn state, when controlled. */ torn?: boolean; defaultTorn?: boolean; onTornChange?: (torn: boolean) => void; /** How far the stub swings by the time the last bridge breaks, in degrees. */ tearAngle?: number; className?: string; }; function Field({ label, value }: { label: string; value: string }) { return ( <span className="flex flex-col gap-0.5"> <span className="text-[11px] font-medium text-neutral-400 dark:text-neutral-500">{label}</span> <span className="text-sm font-medium tabular-nums text-neutral-900 dark:text-neutral-50">{value}</span> </span> ); } export function BoardingPass({ from = "SFO", to = "JFK", fromCity = "San Francisco", toCity = "New York", passenger = "Maya Chen", flight = "UB 204", date = "14 Oct", gate = "B12", seat = "14A", boarding = "09:40", torn: tornProp, defaultTorn = false, onTornChange, tearAngle = 14, className, }: BoardingPassProps) { const reduceMotion = useReducedMotion(); const [inner, setInner] = useState(defaultTorn); const torn = tornProp ?? inner; const set = (next: boolean) => { setInner(next); onTornChange?.(next); }; const webgl = useSyncExternalStore(noSubscribe, supportsWebGL2, () => false); const paper = webgl && !reduceMotion; const stub = useRef<HTMLButtonElement>(null); const [flying, setFlying] = useState<Omit<TearCanvasProps, "controlRef" | "onFront" | "onFree" | "onBack" | "onDone"> | null>(null); const control = useRef<TearControl | null>(null); const drag = useRef<{ id: number; x: number; y: number } | null>(null); const [front, setFront] = useState(HEIGHT); const wasTorn = useRef(torn); const seed = `${flight}${seat}${passenger}`; const edge = useMemo(() => tearEdge(seed, HEIGHT), [seed]); // Only as far down as the tear has run; the perforation is still whole below it. const tornFront = torn && !flying ? HEIGHT : front; // Each tear gets its own copy of the edge: a rough pull reshapes it as the bridges go. const [live, setLive] = useState<{ edge: Float32Array; rev: number } | null>(null); const torn_ = live?.edge ?? edge; const tornMask = useMemo( () => bodyEdgeMask(torn_, BODY_W, HEIGHT, seed, tornFront), // `rev` stands in for the edge array changing in place. // eslint-disable-next-line react-hooks/exhaustive-deps [torn_, seed, tornFront, live?.rev], ); // Paints the stub into a texture and hands it to the paper mesh. const begin = (auto: boolean, grip?: [number, number]) => { if (!stub.current) return false; const dpr = Math.min(window.devicePixelRatio || 1, 2); const painted = paintStub(stub.current, EDGE_MAX, dpr); if (!painted) return false; const [r, g, b] = (getComputedStyle(stub.current).backgroundColor.match(/[\d.]+/g) ?? ["255", "255", "255"]).map(Number); const dark = 0.2126 * r! + 0.7152 * g! + 0.0722 * b! < 128; setFront(PERF.start); const fresh = Float32Array.from(edge); setLive({ edge: fresh, rev: 0 }); setFlying({ stub: painted, edge: fresh, seed, auto, grip, maxAngle: (tearAngle * Math.PI) / 180, dpr, fibre: dark ? [0.45, 0.45, 0.46] : [0.86, 0.86, 0.85] }); return true; }; // Torn from outside (the controlled prop): tear straight through. useLayoutEffect(() => { const was = wasTorn.current; wasTorn.current = torn; if (!torn || was || !paper || flying) return; begin(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, [torn, paper]); // While pulling, the grabbing hand stays put wherever the pointer wanders. const grabbing = useRef<HTMLStyleElement | null>(null); const holdCursor = (on: boolean) => { if (on && !grabbing.current) { const el = document.createElement("style"); el.textContent = "*{cursor:grabbing!important;user-select:none!important}"; document.head.appendChild(el); grabbing.current = el; } else if (!on) { grabbing.current?.remove(); grabbing.current = null; } }; useEffect(() => () => grabbing.current?.remove(), []); // Only a pull tears: the tear runs as far down as the pointer has travelled, and stops when it does. const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => { if (!paper || torn || e.button !== 0) return; const r = e.currentTarget.getBoundingClientRect(); const grip: [number, number] = [e.clientX - r.left, e.clientY - r.top]; // A part-torn stub is already on the canvas: take hold of it again. A fresh one grabs as its canvas starts. if (flying) control.current?.grab(...grip); else if (!begin(false, grip)) return; e.currentTarget.setPointerCapture(e.pointerId); drag.current = { id: e.pointerId, x: e.clientX, y: e.clientY }; holdCursor(true); }; const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => { const d = drag.current; if (!d || d.id !== e.pointerId) return; control.current?.pull(e.clientX - d.x, e.clientY - d.y); }; const onPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => { const d = drag.current; if (!d || d.id !== e.pointerId) return; drag.current = null; holdCursor(false); const c = control.current; if (!c) return; // With only a bridge or two left, the paper can't hold the stub's weight. if (c.progress() >= 0.85) c.finish(); else c.release(); }; const free = () => { if (!torn) set(true); }; const vibrate = () => navigator.vibrate?.(4); const ease = [0.23, 1, 0.32, 1] as const; const t = reduceMotion ? { duration: 0 } : { duration: 0.45, ease }; const bodyMask = maskStyle(bites("right")); if (torn || flying) { // The torn line goes on top of the bites, as one more layer that has to be opaque. bodyMask.maskImage += `,${tornMask}`; bodyMask.WebkitMaskImage += `,${tornMask}`; bodyMask.maskSize += ",100% 100%"; bodyMask.WebkitMaskSize += ",100% 100%"; bodyMask.maskRepeat += ",no-repeat"; bodyMask.WebkitMaskRepeat += ",no-repeat"; } return ( <div className={cn("flex items-center drop-shadow-[0_0_0.5px_rgb(0_0_0/0.35)] dark:drop-shadow-[0_0_0.5px_rgb(255_255_255/0.4)]", className)}> <div className="drop-shadow-[0_10px_16px_rgb(0_0_0/0.06)] dark:drop-shadow-none"> <section aria-label={`Boarding pass, ${from} to ${to}`} className="flex flex-col justify-between bg-white p-5 dark:bg-neutral-900" style={{ ...bodyMask, width: BODY_W, height: HEIGHT, borderRadius: `${RADIUS}px 0 0 ${RADIUS}px` }} > <div className="flex items-center justify-between text-[13px]"> <span className="font-medium text-neutral-900 dark:text-neutral-50">{flight}</span> <span className="relative grid h-6 items-center justify-items-end"> <motion.span initial={false} animate={{ opacity: torn ? 0 : 1, y: torn ? -4 : 0 }} transition={t} className="col-start-1 row-start-1 text-neutral-400 dark:text-neutral-500" > {date} </motion.span> <motion.span initial={false} animate={{ opacity: torn ? 1 : 0, y: torn ? 0 : 4 }} transition={t} className="col-start-1 row-start-1 flex items-center gap-1.5 rounded-full bg-neutral-900 py-1 pr-2.5 pl-2 text-xs font-medium text-white dark:bg-white dark:text-neutral-900" > <IconCheck size={12} stroke={3} /> Checked in </motion.span> </span> </div> <div className="flex items-center gap-3"> <span className="flex flex-col"> <span className="text-[28px] leading-none font-medium tracking-[-0.02em] text-neutral-900 dark:text-neutral-50">{from}</span> <span className="mt-1.5 text-xs text-neutral-400 dark:text-neutral-500">{fromCity}</span> </span> <span className="flex flex-1 items-center gap-2 text-neutral-300 dark:text-neutral-600" aria-hidden> <span className="h-px flex-1 bg-current" /> <IconPlane size={16} className="shrink-0 rotate-45" /> <span className="h-px flex-1 bg-current" /> </span> <span className="flex flex-col items-end"> <span className="text-[28px] leading-none font-medium tracking-[-0.02em] text-neutral-900 dark:text-neutral-50">{to}</span> <span className="mt-1.5 text-xs text-neutral-400 dark:text-neutral-500">{toCity}</span> </span> </div> <div className="flex items-end justify-between"> <Field label="Passenger" value={passenger} /> <Field label="Gate" value={gate} /> <Field label="Boards" value={boarding} /> </div> </section> </div> {/* Width collapses on tearing so the ticket recentres while the stub, which overflows it, flies off. */} <motion.div initial={false} animate={{ width: torn ? 0 : STUB_W }} transition={paper && torn ? { duration: 0.5, ease, delay: 0.45 } : t} className="relative shrink-0" style={{ zIndex: 1, height: HEIGHT }} > {flying && ( <TearCanvas {...flying} controlRef={control} onFront={(y) => { setFront(y); setLive((l) => l && { ...l, rev: l.rev + 1 }); }} onFree={free} onBack={() => setFlying(null)} onDone={() => setFlying(null)} onSnap={vibrate} /> )} <motion.button ref={stub} type="button" disabled={torn} tabIndex={torn ? -1 : 0} aria-hidden={torn || undefined} aria-label="Tear off the stub to check in" // Pointer presses are handled as drags; this is the keyboard's way in. onClick={(e) => { if (!paper) set(true); else if (e.detail === 0) { if (flying) control.current?.finish(); else begin(true); } }} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} initial={false} animate={ paper ? { opacity: torn || flying ? 0 : 1, transition: { duration: 0 } } : torn ? { x: 36, y: 40, rotate: tearAngle, opacity: 0, filter: "blur(4px)" } : { x: 0, y: 0, rotate: 0, opacity: 1, filter: "blur(0px)" } } transition={t} // The paper version is painted from the stub's layout, so it stays still under the finger. whileTap={torn || reduceMotion || paper ? undefined : { x: 3, rotate: 1.5 }} className={cn( "group absolute top-0 left-0 flex h-full origin-bottom-left touch-none flex-col items-center justify-between bg-neutral-50 p-4 text-neutral-900 outline-none dark:bg-neutral-800/80 dark:text-neutral-50", "focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-inset", paper ? "cursor-grab active:cursor-grabbing" : "cursor-pointer", )} style={{ width: STUB_W, borderRadius: `0 ${RADIUS}px ${RADIUS}px 0`, ...maskStyle(bites("left")) }} > {/* The perforation: holes where the gradient is solid, the paper bridges between. */} <span data-tear-perf className="pointer-events-none absolute left-0 w-[1.5px] text-neutral-300 dark:text-neutral-600" style={{ top: PERF.start, height: PERF.end - PERF.start, backgroundImage: `repeating-linear-gradient(to bottom, currentColor 0 ${PERF.hole}px, transparent ${PERF.hole}px ${PERF.pitch}px)`, }} /> <span className="flex flex-col items-center gap-0.5"> <span data-tear-text className="text-[11px] font-medium text-neutral-400 dark:text-neutral-500">Seat</span> <span data-tear-text className="text-xl leading-none font-medium tracking-[-0.01em]">{seat}</span> </span> <Code seed={`${flight}${seat}${passenger}`} /> <span data-tear-text className="text-[11px] font-medium text-neutral-400 transition-colors duration-150 group-hover:text-neutral-900 dark:text-neutral-500 dark:group-hover:text-neutral-50"> {paper ? "Pull to check in" : "Tear to check in"} </span> </motion.button> </motion.div> </div> ); } type TearCanvasProps = { stub: HTMLCanvasElement; edge: Float32Array; seed: string; auto: boolean; /** Where the press that started it took hold, on the stub. */ grip?: [number, number]; maxAngle: number; dpr: number; fibre: [number, number, number]; controlRef: React.RefObject<TearControl | null>; onSnap?: () => void; onFront: (y: number) => void; onFree: () => void; onBack: () => void; onDone: () => void; }; function TearCanvas({ stub, edge, seed, auto, grip, maxAngle, dpr, fibre, controlRef, onSnap, onFront, onFree, onBack, onDone }: TearCanvasProps) { const ref = useRef<HTMLCanvasElement>(null); const snap = useEffectEvent(() => onSnap?.()); const free = useEffectEvent(onFree); const moved = useEffectEvent(onFront); const back = useEffectEvent(onBack); const done = useEffectEvent(onDone); const w = ROOM.left + STUB_W + ROOM.right; const h = ROOM.top + HEIGHT + ROOM.bottom; useLayoutEffect(() => { const canvas = ref.current!; canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); const run = runTear({ canvas, stub, edge, seed, auto, width: STUB_W, height: HEIGHT, ext: EDGE_MAX, origin: [ROOM.left, ROOM.top], maxAngle, notch: NOTCH, radius: RADIUS, fibre, onSnap: () => snap(), onFront: (y) => moved(y), onFree: () => free(), onBack: () => back(), onDone: () => done(), }); controlRef.current = run.control; if (!auto && grip) run.control.grab(...grip); return () => { controlRef.current = null; run.dispose(); }; }, [stub, edge, seed, auto, grip, maxAngle, dpr, fibre, controlRef, w, h]); return ( <canvas ref={ref} aria-hidden className="pointer-events-none absolute" style={{ left: -ROOM.left, top: -ROOM.top, width: w, height: h }} /> ); }
Adds the files to
components/uibrik/boarding-pass and installs @tabler/icons-react and motion. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/boarding-pass.Props
BoardingPass- from
- Type string
- "SFO"
- Departure airport code.
- to
- Type string
- "JFK"
- Arrival airport code.
- passenger
- Type string
- "Maya Chen"
- Passenger name.
- flight
- Type string
- "UB 204"
- Flight number.
- gate
- Type string
- "B12"
- Boarding gate.
- seat
- Type string
- "14A"
- Seat, printed large on the stub.
- tearAngle
- Type number
- 14
- How far the stub swings by the time the last bridge breaks.
- fromCity
- Type string
- "San Francisco"
- City under the departure code.
- toCity
- Type string
- "New York"
- City under the arrival code.
- date
- Type string
- "14 Oct"
- Shown top right until the stub is torn.
- boarding
- Type string
- "09:40"
- Boarding time.
- torn
- Type boolean
- –
- Torn state, when controlled.
- defaultTorn
- Type boolean
- false
- Initial torn state when uncontrolled.
- onTornChange
- Type (torn: boolean) => void
- –
- Called when the stub is torn.
- className
- Type string
- –
- Classes for the wrapper.