Cards
BoardingPass
A paper boarding pass with a perforated stub. Pull the stub or press it and it tears off, the plane crosses the route and the pass gets stamped. Hover runs a scanner over the barcode.
Download
- React
- Tailwind CSS v4
- motion
components/ui/boarding-pass.tsx
"use client";
import { useEffect, useRef, useState, type PointerEvent } from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react";
type BoardingPassProps = {
from: string;
to: string;
fromCity?: string;
toCity?: string;
passenger?: string;
airline?: string;
flight?: string;
date?: string;
boarding?: string;
gate?: string;
seat?: string;
/** Header and code colour. */
color?: string;
/** How far to pull the stub before it tears, as a share of its width. */
pull?: number;
onTear?: () => void;
className?: string;
};
// The same flight always prints the same barcode, so server and browser agree.
function barcode(code: string) {
let h = 2166136261;
for (const c of code) h = Math.imul(h ^ c.charCodeAt(0), 16777619);
const bars: { x: number; w: number }[] = [];
for (let x = 0; x < 100; ) {
h = Math.imul(h ^ x, 2654435761) >>> 0;
const w = 0.8 + (h >>> 29) * 0.5;
bars.push({ x, w });
x += w + 0.9 + ((h >>> 26) & 7) * 0.32;
}
return bars;
}
function Field({ label, value }: { label: string; value?: string }) {
if (!value) return null;
return (
<span className="block min-w-0">
<span className="block text-[0.62em] uppercase tracking-[0.14em] text-[#7b7466]">{label}</span>
<span className="block truncate font-mono text-[1.02em] font-bold">{value}</span>
</span>
);
}
const notches = (side: "left" | "right") => {
const half = (y: string) => `radial-gradient(circle 0.85em at ${side} ${y}, transparent 97%, #000) ${y} / 100% 51% no-repeat`;
const mask = `${half("top")}, ${half("bottom")}`;
return { mask, WebkitMask: mask };
};
/* BoardingPass: a paper pass with a perforated stub. Pull the stub or press it, and it tears off and the pass gets stamped. */
export function BoardingPass({
from,
to,
fromCity,
toCity,
passenger = "Guest",
airline = "Boarding pass",
flight,
date,
boarding,
gate,
seat,
color = "#0e5a63",
pull = 0.45,
onTear,
className = "",
}: BoardingPassProps) {
const reduce = useReducedMotion();
const [gone, setGone] = useState(false);
const stub = useRef<HTMLButtonElement>(null);
const again = useRef<HTMLButtonElement>(null);
const grab = useRef<{ x: number; id: number } | null>(null);
const pulled = useRef(false);
const acted = useRef(false);
const x = useMotionValue(0);
const y = useMotionValue(0);
const fade = useMotionValue(1);
const rotate = useTransform(x, (v) => Math.min(v * 0.16, 26));
// The focused stub flies away, so focus moves to what replaces it.
useEffect(() => {
if (acted.current) (gone ? again : stub).current?.focus({ preventScroll: true });
}, [gone]);
function tear(width: number) {
if (gone) return;
acted.current = true;
setGone(true);
onTear?.();
if (reduce) {
fade.set(0);
return;
}
animate(x, width * 1.5, { duration: 0.55, ease: [0.3, 0, 0.5, 1] });
animate(y, width * 0.7, { duration: 0.55, ease: [0.5, 0, 1, 0.6] });
animate(fade, 0, { duration: 0.5, delay: 0.05 });
}
function reissue() {
x.jump(0);
y.jump(0);
setGone(false);
animate(fade, 1, { duration: reduce ? 0 : 0.3 });
}
function down(e: PointerEvent<HTMLButtonElement>) {
if (gone) return;
grab.current = { x: e.clientX, id: e.pointerId };
pulled.current = false;
e.currentTarget.setPointerCapture(e.pointerId);
}
function move(e: PointerEvent<HTMLButtonElement>) {
if (grab.current?.id !== e.pointerId) return;
const dx = Math.max(0, e.clientX - grab.current.x);
if (dx > 4) pulled.current = true;
x.set(dx);
}
function up(e: PointerEvent<HTMLButtonElement>) {
if (grab.current?.id !== e.pointerId) return;
grab.current = null;
const width = e.currentTarget.offsetWidth;
if (x.get() >= width * pull) tear(width);
else animate(x, 0, { type: "spring", stiffness: 500, damping: 22 });
}
return (
<div className={`@container w-full max-w-[600px] ${className}`}>
<div className="relative flex h-[15.6em] select-none text-[2.5cqw] leading-tight text-[#20252b] [filter:drop-shadow(0_0.5em_0.7em_rgba(0,0,0,0.38))]">
<div className="relative flex min-w-0 flex-[29] flex-col overflow-hidden bg-[#f7f2e6]" style={notches("right")}>
<div className="flex items-center justify-between px-[1.5em] py-[0.55em] text-[#f7f2e6]" style={{ background: color }}>
<span className="truncate text-[1.05em] font-bold tracking-wide">{airline}</span>
<span className="shrink-0 font-mono text-[0.72em] uppercase tracking-[0.2em] opacity-80">Boarding pass</span>
</div>
<div className="flex items-center gap-[0.9em] px-[1.5em] pt-[0.8em]">
<span className="min-w-0">
<span className="block font-mono text-[3.5em] font-extrabold leading-none tracking-tight" style={{ color }}>{from}</span>
{fromCity && <span className="block truncate text-[0.82em] text-[#5e584c]">{fromCity}</span>}
</span>
<span aria-hidden className="relative h-[1.5em] min-w-[3em] flex-1 border-b-[0.13em] border-dotted border-[#a9a18f]">
<motion.svg
viewBox="0 0 24 24"
className="absolute bottom-[-0.72em] h-[1.4em] w-[1.4em]"
style={{ fill: color }}
initial={false}
animate={{ left: gone ? "calc(100% - 1.4em)" : "0%" }}
transition={{ duration: reduce ? 0 : 0.9, ease: [0.4, 0, 0.2, 1] }}
>
<path d="M22 12c0-.9-.9-1.6-2-1.6h-5.2L9 3H7l2.8 7.4H4.8L3.2 8.5H1.7l1 3.5-1 3.5h1.5l1.6-1.9h5L7 21h2l5.8-7.4H20c1.1 0 2-.7 2-1.6z" />
</motion.svg>
</span>
<span className="min-w-0 text-right">
<span className="block font-mono text-[3.5em] font-extrabold leading-none tracking-tight" style={{ color }}>{to}</span>
{toCity && <span className="block truncate text-[0.82em] text-[#5e584c]">{toCity}</span>}
</span>
</div>
<div className="mt-auto grid grid-cols-[2.1fr_1fr_1fr_1fr] gap-x-[0.8em] px-[1.5em] pb-[0.5em]">
<span className="col-span-4 pb-[0.5em]"><Field label="Passenger" value={passenger} /></span>
<Field label="Flight" value={[flight, date].filter(Boolean).join(" · ")} />
<Field label="Boards" value={boarding} />
<Field label="Gate" value={gate} />
<Field label="Seat" value={seat} />
</div>
{gone && (
<motion.span
aria-hidden
className="pointer-events-none absolute bottom-[2.7em] right-[1em] border-[0.2em] border-double border-[#b3261e] px-[0.5em] py-[0.1em] font-mono text-[1.5em] font-black uppercase tracking-widest text-[#b3261e] mix-blend-multiply"
initial={reduce ? false : { scale: 2.4, opacity: 0, rotate: -3 }}
animate={{ scale: 1, opacity: 0.86, rotate: -11 }}
transition={{ type: "spring", stiffness: 520, damping: 24, delay: 0.32 }}
>
Boarded
</motion.span>
)}
</div>
<div className="relative flex-[11]">
{gone && (
<span className="absolute inset-[0.5em] grid place-items-center rounded-[0.5em] border-[0.1em] border-dashed border-white/25">
<button ref={again} type="button" onClick={reissue} className="rounded-[0.3em] px-[0.7em] py-[0.3em] text-[0.95em] text-white/75 hover:text-white">
Reissue
</button>
</span>
)}
<motion.button
ref={stub}
type="button"
disabled={gone}
aria-hidden={gone}
aria-label={`Tear off the stub, ${from} to ${to}`}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
onClick={(e) => !pulled.current && tear(e.currentTarget.offsetWidth)}
initial="rest"
animate="rest"
whileHover="scan"
whileFocus="scan"
style={{ ...notches("left"), x, y, rotate, opacity: fade, transformOrigin: "0% 100%" }}
className={`absolute inset-0 flex cursor-grab touch-pan-y flex-col bg-[#f7f2e6] px-[0.95em] pb-[0.7em] pt-[0.65em] text-left active:cursor-grabbing ${gone ? "invisible" : ""}`}
>
<span aria-hidden className="absolute inset-y-[1em] left-0 w-[0.28em] bg-[radial-gradient(circle,rgba(32,37,43,0.5)_38%,transparent_44%)] bg-[length:100%_0.62em]" />
<span className="font-mono text-[1.2em] font-extrabold" style={{ color }}>{from} › {to}</span>
<span className="mt-[0.3em] grid grid-cols-2 gap-x-[0.4em]">
<Field label="Seat" value={seat} />
<Field label="Gate" value={gate} />
</span>
<span className="relative mt-auto block h-[6em] overflow-hidden">
<svg aria-hidden viewBox="0 0 100 100" preserveAspectRatio="none" className="h-full w-full">
{barcode(`${flight}${from}${to}${seat}`).map((b) => (
<rect key={b.x} x={b.x} y="0" width={b.w} height="100" fill="#20252b" />
))}
</svg>
<motion.span
aria-hidden
className="absolute inset-x-0 h-[0.14em] bg-[#ff3b30] shadow-[0_0_0.6em_0.14em_#ff3b30]"
variants={{
rest: { opacity: 0, top: "0%" },
scan: { opacity: 1, top: ["0%", "96%", "0%"], transition: { opacity: { duration: 0.15 }, top: { duration: 1.5, ease: "easeInOut", repeat: Infinity } } },
}}
/>
</span>
</motion.button>
</div>
</div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/boarding-pass.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/boarding-pass-demo.tsx
import { BoardingPass } from "@/components/ui/boarding-pass"; export default function BoardingPassExample() { return ( <div className="grid h-full place-items-center overflow-hidden bg-[#10212b] bg-[radial-gradient(circle_at_30%_20%,#1c3b4a,transparent_65%)] p-4"> <BoardingPass from="MAA" to="SIN" fromCity="Chennai" toCity="Singapore" passenger="Rajesh Kumar" airline="Blacky Air" flight="BK 417" date="12 NOV" boarding="06:40" gate="B7" seat="14C" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| from | string | — | Departure code. |
| to | string | — | Arrival code. |
| fromCity | string | — | City under the departure code. |
| toCity | string | — | City under the arrival code. |
| passenger | string | "Guest" | Name printed on the pass. |
| airline | string | "Boarding pass" | Name in the header. |
| flight | string | — | Flight number. |
| date | string | — | Date beside the flight number. |
| boarding | string | — | Boarding time. |
| gate | string | — | Gate. |
| seat | string | — | Seat. |
| color | string | "#0e5a63" | Header and code colour. |
| pull | number | 0.45 | How far to pull the stub before it tears, as a share of its width. |
| onTear | () => void | — | Runs when the stub comes off. |
| className | string | "" | Size. Everything inside scales with the width. |