Ticket Button
A call to action printed like an old cinema ticket: a vermilion body with the label, a marigold stub with a star and a serial number, scalloped ends, and a perforated line between the two. On hover the stub tears off: the halves slide apart and swing open from the top. The colours stay as printed.
The ticket shape
Each half is cut with a CSS mask made of three radial gradients. One is 8px tall and repeats down the outer edge, which gives the row of scallops. The other two take a quarter circle out of each corner on the tear line, and where the halves meet those quarters join into the round notches a real ticket has at its perforation. The dashed line is a border on the stub in its ink colour.
A mask also clips box-shadow, so the shadow under each half is a
drop-shadow filter on its wrapper. It follows the cut outline, scallops
included.
Print details
Inside each half there is a thin frame in the ink colour at 45% opacity, set 6px in so it clears the notches. The caption and serial number are 8px mono, and they are decoration, so they are hidden from screen readers. A layer of noise is multiplied over the colour to break up the flat fill, the way cheap paper takes ink unevenly.
The tear
The body hinges at its bottom right corner and the stub at its bottom left, so
the gap is widest at the top. Each swings 6° (tilt), slides 9px away from the
tear line (spread), and lifts by a pixel, all on one soft spring. The hover
target is the button itself, which never moves, so the gap doesn't drop the
hover. Keyboard focus plays the same animation, and with reduced motion the
ticket stays whole.
Palettes
The four colours are props: color and paper are the body and its ink,
accent and ink are the stub and its ink. The defaults are the only place this library uses
strong colour, and they are fixed values, so the ticket looks the same in the
light and dark themes. Keep paper light and pick a color dark enough for
the label to read against it.
Usage
<TicketButton label="Book tickets" href="/tickets" />
<TicketButton
label="Join the tour"
caption="Summer 26"
serial="No. 0113"
color="#2447C9"
accent="#FF9EBB"
/>
Pass href for a link, or leave it out and use onClick for a button.
"use client";
import { StarFourIcon, StarIcon } from "@phosphor-icons/react";
import { motion, useReducedMotion, type Variants } from "motion/react";
import { cn } from "@/lib/utils";
export type TicketButtonProps = {
label?: string;
/** Small line printed above the label. */
caption?: string;
/** Printed on the stub under the icon. */
serial?: string;
/** Renders a link when set, a button otherwise. */
href?: string;
target?: string;
rel?: string;
/** Shown on the stub. */
icon?: React.ReactNode;
/** Body colour. */
color?: string;
/** Ink printed on the body. */
paper?: string;
/** Stub colour. */
accent?: string;
/** Ink printed on the stub. */
ink?: string;
/** How far each half swings when the stub tears off, in degrees. */
tilt?: number;
/** How far each half slides away from the tear line, in px. */
spread?: number;
className?: string;
onClick?: React.MouseEventHandler<HTMLElement>;
};
const GRAIN =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E\")";
const hole = (at: string, r: number) =>
"radial-gradient(circle at " + at + ", #0000 " + r + "px, #000 " + (r + 0.5) + "px)";
/**
* Cuts one half of the ticket: a column of scallops down its outer edge and a
* quarter circle out of both corners on the tear line. The layers intersect,
* so every hole cuts through.
*/
function cut(outer: "0" | "100%"): React.CSSProperties {
const tear = outer === "0" ? "100%" : "0";
return {
maskImage: [
hole(outer + " 50%", 2.5),
hole(tear + " 0", 5),
hole(tear + " 100%", 5),
].join(", "),
maskSize: "100% 8px, 100% 100%, 100% 100%",
maskComposite: "intersect",
};
}
const BODY_CUT = cut("0");
const STUB_CUT = cut("100%");
const TEAR = { type: "spring", stiffness: 260, damping: 18 } as const;
// A filter, not a box-shadow, so the shadow follows the cut outline.
const PIECE =
"relative block [filter:drop-shadow(0_1px_0_rgb(0_0_0/0.18))_drop-shadow(0_3px_5px_rgb(0_0_0/0.16))]";
const FACE = "relative flex h-16 flex-col justify-center overflow-hidden";
const FRAME = "pointer-events-none absolute inset-1.5 rounded-[3px] border border-current/45";
const SMALL = "font-mono text-[8px] leading-none font-medium";
function Grain() {
return (
<span
className="pointer-events-none absolute inset-0 opacity-30 mix-blend-multiply"
style={{ backgroundImage: GRAIN }}
/>
);
}
export function TicketButton({
label = "Book tickets",
caption = "Admit one",
serial = "No. 0427",
href,
target,
rel,
icon,
color = "#CB3520",
paper = "#FFF0D2",
accent = "#F6B936",
ink = "#2B1710",
tilt = 6,
spread = 9,
className,
onClick,
}: TicketButtonProps) {
const reduceMotion = useReducedMotion();
// The halves hinge at the bottom of the tear line and swing apart, so the
// gap opens from the top like a stub being torn off.
const swing = (dir: 1 | -1): Variants => ({
rest: { rotate: 0, x: 0, y: 0, transition: TEAR },
active: reduceMotion ? {} : { rotate: dir * tilt, x: dir * spread, y: -1, transition: TEAR },
});
const shared = {
initial: "rest",
animate: "rest",
whileHover: "active",
whileFocus: "active",
whileTap: reduceMotion ? undefined : { scale: 0.97 },
onClick,
className: cn(
"inline-flex cursor-pointer rounded-sm whitespace-nowrap uppercase outline-offset-8 outline-ring select-none focus-visible:outline-2",
className
),
};
const content = (
<>
<motion.span variants={swing(-1)} className={cn(PIECE, "origin-bottom-right")}>
<span
style={{ ...BODY_CUT, backgroundColor: color, color: paper }}
className={cn(FACE, "gap-1.5 pr-6 pl-7")}
>
<span aria-hidden className={FRAME} />
<span aria-hidden className={cn(SMALL, "flex items-center gap-1.5 tracking-[0.22em] opacity-80")}>
<StarFourIcon weight="fill" className="size-2" />
{caption}
<StarFourIcon weight="fill" className="size-2" />
</span>
<span className="text-[15px] leading-none font-semibold tracking-[0.06em]">
{label}
</span>
<Grain />
</span>
</motion.span>
<motion.span aria-hidden variants={swing(1)} className={cn(PIECE, "origin-bottom-left")}>
<span
style={{ ...STUB_CUT, backgroundColor: accent, color: ink }}
className={cn(FACE, "w-17 items-center gap-1.5 border-l-[1.5px] border-dashed border-current/50")}
>
<span className={FRAME} />
{icon ?? <StarIcon weight="fill" className="size-5" />}
<span className={cn(SMALL, "tracking-[0.1em] tabular-nums")}>{serial}</span>
<Grain />
</span>
</motion.span>
</>
);
return href ? (
<motion.a href={href} target={target} rel={rel} {...shared}>
{content}
</motion.a>
) : (
<motion.button type="button" {...shared}>
{content}
</motion.button>
);
}