Cards & Layoutv0.4.0New
Burst Pack Opening
A foil card pack you tear open in a burst of pixel rays, cards and confetti.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/burst-pack-opening.jsonManual
Install the dependencies
npm install clsx lucide-react motion radix-ui tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/glyph/canvas-loop.ts/** * The render loop every glyph and dither canvas shares: sizes the canvas to its host at a capped * pixel ratio, runs only while the host is on screen, the tab is visible, nothing is paused and * the user hasn't asked for reduced motion, and otherwise keeps one up-to-date still frame. * Framework-free: start it in an effect and dispose it in the cleanup. Browser only. */ export interface CanvasLoopFrame { /** Seconds since the previous frame (capped at 0.05), or 0 for a redraw without advancing. */ dt: number; /** Seconds the loop has been running. */ time: number; /** True under reduced motion: draw a calm, finished-looking frame. */ still: boolean; /** Host size in CSS pixels. */ width: number; height: number; /** Device pixel ratio of the canvas (capped). */ dpr: number; } export interface CanvasLoopOptions { /** Element observed for size and visibility. */ host: HTMLElement; /** Canvas sized to the host times the pixel ratio. */ canvas: HTMLCanvasElement; /** Advance by `frame.dt` and draw. Called every animation frame while running, and with dt 0 after resizes and redraws. */ render: (frame: CanvasLoopFrame) => void; /** Called after the canvas is resized, before it is redrawn. */ resize?: (frame: CanvasLoopFrame) => void; /** Read before every frame; while it returns true the loop stops and the last frame stays. */ isPaused?: () => boolean; /** Highest device pixel ratio used. @default 2 */ maxDpr?: number; /** Margin around the viewport that still counts as on screen. @default "120px" */ rootMargin?: string; } export interface CanvasLoop { /** Re-checks paused, visibility and reduced motion, and starts or stops the loop. Call it when props change. */ wake(): void; /** Draws one frame without advancing (no-op while the loop is running, since the next frame will). */ redraw(): void; /** Stops the loop and removes every observer and listener. */ dispose(): void; } export function runCanvasLoop(options: CanvasLoopOptions): CanvasLoop { const { host, canvas, rootMargin = "120px" } = options; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); let width = 0; let height = 0; let dpr = 1; let raf = 0; let last = 0; let time = 0; let inView = false; let disposed = false; const frame = (dt: number): CanvasLoopFrame => ({ dt, time, still: reducedMotion.matches, width, height, dpr }); const shouldRun = () => !disposed && inView && !document.hidden && !reducedMotion.matches && !options.isPaused?.(); const tick = (now: number) => { raf = 0; if (!shouldRun()) return; const dt = last ? Math.min(0.05, (now - last) / 1000) : 0; last = now; time += dt; options.render(frame(dt)); raf = requestAnimationFrame(tick); }; const wake = () => { if (disposed) return; if (shouldRun()) { if (!raf) { last = 0; raf = requestAnimationFrame(tick); } return; } if (raf) cancelAnimationFrame(raf); raf = 0; }; const redraw = () => { if (!disposed && !raf && width > 0) options.render(frame(0)); }; const resize = () => { width = Math.max(1, host.clientWidth); height = Math.max(1, host.clientHeight); dpr = Math.min(window.devicePixelRatio || 1, options.maxDpr ?? 2); const w = Math.max(1, Math.round(width * dpr)); const h = Math.max(1, Math.round(height * dpr)); if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; } options.resize?.(frame(0)); // Resizing clears the canvas, so repaint right away even while running. options.render(frame(0)); }; const resizeObserver = new ResizeObserver(resize); const intersection = new IntersectionObserver( ([entry]) => { inView = entry?.isIntersecting ?? true; wake(); }, { rootMargin }, ); const onVisibility = () => wake(); const onMotionChange = () => { wake(); redraw(); }; resize(); resizeObserver.observe(host); intersection.observe(host); document.addEventListener("visibilitychange", onVisibility); reducedMotion.addEventListener("change", onMotionChange); return { wake, redraw, dispose() { disposed = true; if (raf) cancelAnimationFrame(raf); raf = 0; resizeObserver.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", onVisibility); reducedMotion.removeEventListener("change", onMotionChange); }, }; }lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }lib/pixel-art/sprites.ts/** * Palette-indexed sprite sheets for canvas pixel art, and a low-res buffer that is drawn at a * logical pixel size and blitted at a whole-number scale, so pixels stay square and crisp. * Framework-free. The canvas helpers are browser only: call them from effects. */ export interface SpriteSheet { /** Frame width in logical pixels. */ w: number; /** Frame height in logical pixels. */ h: number; /** One palette index per pixel, row by row. Index 0 is transparent. */ frames: Uint8Array[]; /** CSS colors by index. Entry 0 is unused (transparent). */ palette: readonly string[]; } /** * Builds a sheet from character maps, the same authoring format as the pixel icons: one string * per row, one character per pixel, `.` for transparent. `colors` maps each character to a CSS * color, and its key order becomes the palette order (starting at index 1). */ export function spriteSheet(frames: readonly (readonly string[])[], colors: Readonly<Record<string, string>>): SpriteSheet { const keys = Object.keys(colors); const h = frames[0]?.length ?? 0; const w = frames[0]?.[0]?.length ?? 0; return { w, h, palette: ["transparent", ...keys.map((key) => colors[key])], frames: frames.map((rows) => { const data = new Uint8Array(w * h); for (let y = 0; y < h; y++) { const row = rows[y] ?? ""; for (let x = 0; x < w; x++) data[y * w + x] = keys.indexOf(row[x] ?? ".") + 1; } return data; }), }; } export interface DrawSpriteOptions { /** Mirror the frame horizontally. */ flip?: boolean; /** Palette to use instead of the sheet's, e.g. a recolored vest. Same indices. */ palette?: readonly string[]; } /** * Draws one frame at a whole-pixel position (x and y are rounded, so sprites never land between * pixels). Horizontal runs of one color are filled as a single rect. */ export function drawSprite(ctx: CanvasRenderingContext2D, sheet: SpriteSheet, frame: number, x: number, y: number, options: DrawSpriteOptions = {}) { const data = sheet.frames[((frame % sheet.frames.length) + sheet.frames.length) % sheet.frames.length]; if (!data) return; const palette = options.palette ?? sheet.palette; const { w, h } = sheet; const ox = Math.round(x); const oy = Math.round(y); let fill = ""; for (let row = 0; row < h; row++) { let col = 0; while (col < w) { const index = data[row * w + (options.flip ? w - 1 - col : col)]; if (!index) { col++; continue; } let run = 1; while (col + run < w && data[row * w + (options.flip ? w - 1 - col - run : col + run)] === index) run++; const color = palette[index] ?? sheet.palette[index]; if (color !== fill) { fill = color; ctx.fillStyle = color; } ctx.fillRect(ox + col, oy + row, run, 1); col += run; } } } export interface PixelBuffer { /** Offscreen canvas at the logical size. Draw sprites here. */ canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; width: number; height: number; } /** An offscreen canvas at a logical pixel size. Returns null when 2D canvas is unavailable. */ export function createPixelBuffer(width: number, height: number): PixelBuffer | null { const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); if (!ctx) return null; ctx.imageSmoothingEnabled = false; return { canvas, ctx, width, height }; } /** The largest whole-number scale at which a logical size fits the target (at least 1). */ export function integerScale(logicalWidth: number, logicalHeight: number, targetWidth: number, targetHeight: number, max = Infinity) { return Math.max(1, Math.min(max, Math.floor(Math.min(targetWidth / logicalWidth, targetHeight / logicalHeight)))); } export interface BlitOptions { /** Scale to use instead of the largest that fits. Rounded to a whole number. */ scale?: number; /** Horizontal anchor inside the target, 0 (left) to 1 (right). @default 0.5 */ alignX?: number; /** Vertical anchor inside the target, 0 (top) to 1 (bottom). @default 0.5 */ alignY?: number; } /** * Draws a pixel buffer onto a canvas context at a whole-number scale with smoothing off. * `targetWidth` and `targetHeight` are in the context's own pixels (device pixels for a canvas * sized by the shared canvas loop). Returns the scale and offset used, for pointer mapping. */ export function blitPixelBuffer(ctx: CanvasRenderingContext2D, buffer: PixelBuffer, targetWidth: number, targetHeight: number, options: BlitOptions = {}) { const scale = Math.max(1, Math.round(options.scale ?? integerScale(buffer.width, buffer.height, targetWidth, targetHeight))); const width = buffer.width * scale; const height = buffer.height * scale; const x = Math.round((targetWidth - width) * (options.alignX ?? 0.5)); const y = Math.round((targetHeight - height) * (options.alignY ?? 0.5)); ctx.imageSmoothingEnabled = false; ctx.drawImage(buffer.canvas, 0, 0, buffer.width, buffer.height, x, y, width, height); return { scale, x, y, width, height }; } /** * Sizes a visible canvas to a logical pixel size and scales it with CSS by a whole number, for * scenes that draw straight to the canvas without a separate buffer. Returns the scale. */ export function fitPixelCanvas(canvas: HTMLCanvasElement, logicalWidth: number, logicalHeight: number, scale: number) { const whole = Math.max(1, Math.round(scale)); if (canvas.width !== logicalWidth) canvas.width = logicalWidth; if (canvas.height !== logicalHeight) canvas.height = logicalHeight; canvas.style.width = `${logicalWidth * whole}px`; canvas.style.height = `${logicalHeight * whole}px`; canvas.style.imageRendering = "pixelated"; const ctx = canvas.getContext("2d"); if (ctx) ctx.imageSmoothingEnabled = false; return whole; }Copy the source
components/ui/burst-pack-opening/burst-pack-opening.tsx"use client"; import { useEffect, useMemo, useRef, useState, type ComponentProps, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { Dialog } from "radix-ui"; import { AnimatePresence, animate, motion, useMotionTemplate, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react"; import { RotateCw, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { duration, ease, spring } from "@/lib/motion"; import { runCanvasLoop, type CanvasLoop } from "@/lib/glyph/canvas-loop"; import { blitPixelBuffer, createPixelBuffer, type PixelBuffer } from "@/lib/pixel-art/sprites"; export interface BurstPackCard { /** Stable id. */ id: string; /** Card name, shown at the bottom of the card and on its back. */ name: string; /** Full-bleed art: a React node (SVG works best) or an image URL. */ art: ReactNode | string; /** Short stat line under the name, such as "Bounce 7". */ stat?: string; /** Text on the back of the card. */ description?: string; /** "holo" adds a holographic foil that follows the pointer. @default "common" */ rarity?: "common" | "holo"; /** Background behind the art, as any CSS background. */ ground?: string; } export interface BurstPack { /** Stable id. */ id: string; /** Series name, used on the pack and in its accessible name ("Open pack: Cobalt series"). */ label: string; /** Pack color, as any CSS color. */ color: string; /** Emblem in the middle of the pack. @default a silver four-point star */ emblem?: ReactNode; /** Cards inside. Five fill the row. */ cards: BurstPackCard[]; } export interface BurstPackOpeningProps extends Omit<ComponentProps<"div">, "children"> { /** Packs to open, in order. After the last one the first comes around again. */ packs: BurstPack[]; /** Controlled index of the pack on stage. */ activePack?: number; /** Index of the first pack when uncontrolled. @default 0 */ defaultActivePack?: number; /** Called when the next pack drops in. */ onActivePackChange?: (index: number) => void; /** Called when a pack is torn open, with its cards in the order they landed. */ onOpen?: (pack: BurstPack, cards: BurstPackCard[]) => void; /** Called when a revealed card is opened. */ onCardSelect?: (card: BurstPackCard) => void; /** Seed for the card order, landing angles and confetti, so every visitor sees the same opening. @default 229 */ seed?: number; /** Fire the pixel rays and confetti. @default true */ burst?: boolean; /** Small brand text on the pack's label. @default "Series" */ brandLabel?: string; } const PACK_W = 120; const PACK_H = 168; const STAGE_H = 400; const ROW_TOP = 52; const PACK_TOP = 112; /** One burst pixel is this many CSS pixels. */ const PIXEL = 4; const RAINBOW = ["#ff3b3b", "#ff8a1f", "#ffd426", "#35d04f", "#22b8f0", "#4b5bf0", "#a54bf0"]; const CONFETTI = 90; /** A small seeded generator (mulberry32). */ function seededRandom(seed: number) { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function shuffled<T>(items: T[], random: () => number) { const out = items.slice(); for (let i = out.length - 1; i > 0; i--) { const j = Math.floor(random() * (i + 1)); [out[i], out[j]] = [out[j], out[i]]; } return out; } const ZIGZAG_TOP = `polygon(${Array.from({ length: 13 }, (_, i) => `${(i / 12) * 100}% ${i % 2 ? 0 : 5}px`).join(",")},100% 100%,0 100%)`; const ZIGZAG_BOTTOM = `polygon(0 0,100% 0,${Array.from({ length: 13 }, (_, i) => `${100 - (i / 12) * 100}% calc(100% - ${i % 2 ? 0 : 5}px)`).join(",")})`; function StarEmblem() { return ( <svg viewBox="0 0 48 48" className="size-12 drop-shadow-[0_1px_0_rgb(255_255_255/0.6)]" aria-hidden="true"> <defs> <linearGradient id="bpo-silver" x1="0" y1="0" x2="1" y2="1"> <stop offset="0" stopColor="#ffffff" /> <stop offset="0.5" stopColor="#c9ced8" /> <stop offset="1" stopColor="#8f97a8" /> </linearGradient> </defs> <path d="M24 2c2.4 12.6 7.4 17.6 20 20-12.6 2.4-17.6 7.4-20 20-2.4-12.6-7.4-17.6-20-20 12.6-2.4 17.6-7.4 20-20z" fill="url(#bpo-silver)" stroke="rgb(0 0 0 / 0.25)" strokeWidth="1" /> </svg> ); } const FOIL = "linear-gradient(115deg,transparent 20%,rgb(255 255 255/0.55) 38%,rgb(125 211 252/0.5) 46%,rgb(192 132 252/0.5) 54%,rgb(253 230 138/0.5) 62%,transparent 80%)"; interface FaceProps { card: BurstPackCard; className?: string; children?: ReactNode; } /** The front of a card: art, name and stat line. */ function CardFace({ card, className, children }: FaceProps) { return ( <span className={cn("absolute inset-0 block overflow-hidden rounded-[10%/7%] border-[3px] border-white bg-zinc-100 shadow-[0_1px_2px_rgb(0_0_0/0.12),0_10px_22px_-10px_rgb(0_0_0/0.35)]", className)} style={{ background: card.ground }}> {typeof card.art === "string" ? ( <span aria-hidden="true" style={{ backgroundImage: `url(${JSON.stringify(card.art)})` }} className="absolute inset-0 bg-cover bg-center" /> ) : ( <span aria-hidden="true" className="absolute inset-0 [&>svg]:size-full"> {card.art} </span> )} <span className="absolute inset-x-0 bottom-0 bg-[linear-gradient(to_top,rgb(0_0_0/0.72),transparent)] px-[8%] pt-[18%] pb-[6%] text-left text-white"> <span className="block truncate text-[0.72em] leading-tight font-semibold">{card.name}</span> {card.stat ? <span className="block truncate font-mono text-[0.56em] leading-tight opacity-90">{card.stat}</span> : null} </span> {children} </span> ); } interface RevealedCardProps { card: BurstPackCard; slot: number; count: number; tilt: number; fly: boolean; onSelect: (trigger: HTMLElement) => void; } function RevealedCard({ card, slot, count, tilt, fly, onSelect }: RevealedCardProps) { const reduceMotion = useReducedMotion(); const rotateX = useSpring(0, { stiffness: 260, damping: 20 }); const rotateY = useSpring(0, { stiffness: 260, damping: 20 }); const lift = useSpring(0, { stiffness: 300, damping: 22 }); const glareX = useMotionValue(50); const glareY = useMotionValue(50); const glare = useMotionTemplate`radial-gradient(circle at ${glareX}% ${glareY}%, rgb(255 255 255 / 0.55), transparent 55%)`; const foilPosition = useTransform(glareX, (v) => `${v}% 50%`); function onPointerMove(event: ReactPointerEvent<HTMLButtonElement>) { if (event.pointerType === "touch" || reduceMotion) return; const rect = event.currentTarget.getBoundingClientRect(); const x = (event.clientX - rect.left) / rect.width; const y = (event.clientY - rect.top) / rect.height; rotateY.set((x - 0.5) * 16); rotateX.set((0.5 - y) * 16); lift.set(-8); glareX.set(x * 100); glareY.set(y * 100); } function onPointerLeave() { rotateX.set(0); rotateY.set(0); lift.set(0); } // Each card starts at the pack's mouth and arcs up to its slot. const fromCenter = (slot - (count - 1) / 2) * -1; const flight = fly ? { x: [`${fromCenter * 86}%`, `${fromCenter * 40}%`, "0%"], y: ["70%", "-55%", "0%"], scale: [0.35, 0.9, 1], rotate: [tilt * 14, tilt * -5, tilt], opacity: [0, 1, 1] } : { opacity: 1, x: "0%", y: "0%", scale: 1, rotate: tilt }; return ( <motion.li initial={fly ? { opacity: 0 } : { opacity: 0, rotate: tilt }} animate={flight} exit={reduceMotion ? { opacity: 0 } : { x: `${fromCenter * 86 + 260}%`, opacity: 0, rotate: 0, transition: { duration: duration.slow, ease: ease.inOut, delay: 0.02 * slot } }} transition={fly ? { duration: 0.62, delay: 0.12 + slot * 0.07, times: [0, 0.55, 1], ease: ease.out } : { duration: duration.base }} style={{ zIndex: slot }} className="relative -ml-2.5 aspect-[5/7] w-[21%] max-w-[104px] min-w-[72px] shrink-0 text-[15px] [perspective:600px] first:ml-0 @max-[26rem]:-ml-1.5 @max-[26rem]:w-[30%]" > <motion.button type="button" aria-label={`${card.name}${card.rarity === "holo" ? ", holo" : ""}. Open card`} onClick={(event) => onSelect(event.currentTarget)} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave} style={{ rotateX, rotateY, y: lift }} className="absolute inset-0 cursor-pointer rounded-[10%/7%] outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" > <CardFace card={card}> {card.rarity === "holo" ? <motion.span aria-hidden="true" style={{ backgroundImage: FOIL, backgroundSize: "260% 100%", backgroundPosition: foilPosition }} className="absolute inset-0 mix-blend-overlay" /> : null} <motion.span aria-hidden="true" style={{ background: glare }} className="absolute inset-0 opacity-0 transition-opacity duration-200 [button:hover_&]:opacity-60" /> </CardFace> </motion.button> </motion.li> ); } export function BurstPackOpening({ packs, activePack, defaultActivePack = 0, onActivePackChange, onOpen, onCardSelect, seed = 229, burst = true, brandLabel = "Series", className, ...props }: BurstPackOpeningProps) { const reduceMotion = useReducedMotion(); const [internal, setInternal] = useState(defaultActivePack); const index = packs.length ? ((((activePack ?? internal) % packs.length) + packs.length) % packs.length) : 0; const pack = packs[index]; /** How many packs have been opened, so a pack that comes around again deals a new order. */ const [round, setRound] = useState(0); const [phase, setPhase] = useState<"sealed" | "opening" | "revealed" | "sweeping">("sealed"); const [selected, setSelected] = useState<BurstPackCard | null>(null); const [flipped, setFlipped] = useState(false); const [announcement, setAnnouncement] = useState(""); const stageRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null); const packRef = useRef<HTMLButtonElement>(null); const nextRef = useRef<HTMLButtonElement>(null); const fire = useRef<() => void>(() => {}); const drag = useRef<{ x: number; fromRight: boolean; moved: boolean } | null>(null); const timers = useRef<number[]>([]); const focusPack = useRef(false); /** The card button that opened the dialog, so focus can go back to it. */ const opener = useRef<HTMLElement | null>(null); /** 0 sealed, 1 torn all the way across. */ const tear = useMotionValue(0); const [fromRight, setFromRight] = useState(false); const stripClip = useTransform(tear, (v) => (fromRight ? `inset(0 ${v * 100}% 0 0)` : `inset(0 0 0 ${v * 100}%)`)); const curlX = useTransform(tear, (v) => (fromRight ? (1 - v) * PACK_W - 10 : v * PACK_W - 4)); const curlOpacity = useTransform(tear, [0, 0.04, 0.96, 1], [0, 1, 1, 0]); const shake = useMotionValue(0); // The deal: card order and landing angles for this pack, from the seed. const deal = useMemo(() => { if (!pack) return []; const random = seededRandom(seed + index * 101 + round * 7919); return shuffled(pack.cards, random).map((card) => ({ card, tilt: (random() - 0.5) * 6 })); }, [pack, seed, index, round]); useEffect(() => () => timers.current.forEach((timer) => window.clearTimeout(timer)), []); const later = (fn: () => void, ms: number) => { timers.current.push(window.setTimeout(fn, ms)); }; // Rays and confetti: one low-res canvas, one loop that runs only while something is on it. useEffect(() => { const stage = stageRef.current; const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!stage || !canvas || !ctx) return; let buffer: PixelBuffer | null = null; let started = -1; let clock = 0; const cx = new Float32Array(CONFETTI); const cy = new Float32Array(CONFETTI); const cvx = new Float32Array(CONFETTI); const cvy = new Float32Array(CONFETTI); const cColor = new Uint8Array(CONFETTI); const cRest = new Float32Array(CONFETTI); let shots = 0; const loop: CanvasLoop = runCanvasLoop({ host: stage, canvas, isPaused: () => started < 0, resize: (frame) => { buffer = createPixelBuffer(Math.ceil(frame.width / PIXEL), Math.ceil(frame.height / PIXEL)); }, render: (frame) => { ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); if (!buffer || started < 0 || frame.still) return; clock += frame.dt; const t = clock - started; const b = buffer.ctx; b.clearRect(0, 0, buffer.width, buffer.height); const mouthX = buffer.width / 2; const mouthY = (PACK_TOP + 10) / PIXEL; const floor = (ROW_TOP + 150) / PIXEL; // Rays: hard-edged wedges, banded red to violet outward, that expand, hold and break into pixels. if (t < 1.05) { const reach = Math.min(1, t / 0.4) * (buffer.height * 0.62); const breakUp = Math.max(0, (t - 0.62) / 0.4); const top = Math.max(0, Math.floor(mouthY - reach)); for (let band = 0; band < RAINBOW.length; band++) { b.fillStyle = RAINBOW[band]; for (let y = top; y < mouthY; y++) { for (let x = Math.max(0, Math.floor(mouthX - reach)); x < Math.min(buffer.width, mouthX + reach); x++) { const dx = x - mouthX; const dy = mouthY - y; const distance = Math.sqrt(dx * dx + dy * dy); if (distance > reach || distance < 2) continue; if (Math.floor(distance / 5) % RAINBOW.length !== band) continue; // Eleven wedges across a 150° fan, with a gap between each. const angle = Math.atan2(dy, dx); if (angle < 0.26 || angle > 2.88) continue; if (Math.floor(((angle - 0.26) / 2.62) * 21) % 2) continue; // An ordered threshold per pixel, so the fan dissolves in a pixel pattern. if (breakUp > 0 && ((x * 7 + y * 13) % 16) / 16 < breakUp) continue; b.fillRect(x, y, 1, 1); } } } } // Confetti: square pixels under gravity that settle on a floor below the row, then fade. let alive = false; for (let band = 0; band < RAINBOW.length; band++) { b.fillStyle = RAINBOW[band]; for (let i = 0; i < CONFETTI; i++) { if (cColor[i] !== band) continue; if (cRest[i] > 1.1) continue; alive = true; if (cRest[i] === 0) { cvy[i] += 150 * frame.dt; cx[i] += cvx[i] * frame.dt; cy[i] += cvy[i] * frame.dt; if (cy[i] >= floor && cvy[i] > 0) { cy[i] = floor; cRest[i] = 0.001; } } else { cRest[i] += frame.dt; } // Fading is a pixel flicker, not an alpha ramp, to stay hard-edged. if (cRest[i] > 0.5 && (i + Math.floor(cRest[i] * 20)) % 3 === 0) continue; b.fillRect(Math.round(cx[i]), Math.round(cy[i]), 2, 2); } } blitPixelBuffer(ctx, buffer, canvas.width, canvas.height, { scale: canvas.width / buffer.width, alignX: 0.5, alignY: 0 }); if (!alive && t > 1.05) { started = -1; loop.wake(); } }, }); fire.current = () => { if (!buffer || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; const random = seededRandom(seed + 31 * ++shots); const mouthX = buffer.width / 2; const mouthY = (PACK_TOP + 10) / PIXEL; for (let i = 0; i < CONFETTI; i++) { const angle = 0.3 + random() * 2.54; const speed = 40 + random() * 70; cx[i] = mouthX + (random() - 0.5) * 8; cy[i] = mouthY; cvx[i] = Math.cos(angle) * speed; cvy[i] = -Math.sin(angle) * speed; cColor[i] = Math.floor(random() * RAINBOW.length); cRest[i] = 0; } started = clock; loop.wake(); }; return () => { fire.current = () => {}; loop.dispose(); }; }, [seed]); function open() { if (phase !== "sealed" || !pack) return; setPhase("opening"); const finish = () => { // Two or three frames of shake, then the pack gives. if (!reduceMotion) animate(shake, [0, -3, 3, -2, 0], { duration: 0.16 }); later( () => { if (burst) fire.current(); setPhase("revealed"); setAnnouncement(`${deal.length} cards revealed: ${deal.map((entry) => entry.card.name).join(", ")}`); onOpen?.(pack, deal.map((entry) => entry.card)); }, reduceMotion ? 0 : 170, ); }; if (reduceMotion) { tear.jump(1); finish(); } else { animate(tear, 1, { duration: Math.max(0.12, 0.5 * (1 - tear.get())), ease: ease.out, onComplete: finish }); } } function onPackPointerDown(event: ReactPointerEvent<HTMLButtonElement>) { if (phase !== "sealed" || event.button !== 0) return; const rect = event.currentTarget.getBoundingClientRect(); const right = event.clientX - rect.left > rect.width / 2; drag.current = { x: event.clientX, fromRight: right, moved: false }; setFromRight(right); event.currentTarget.setPointerCapture(event.pointerId); } function onPackPointerMove(event: ReactPointerEvent<HTMLButtonElement>) { const state = drag.current; if (!state || phase !== "sealed") return; const travelled = (event.clientX - state.x) * (state.fromRight ? -1 : 1); if (travelled > 6) state.moved = true; if (state.moved) tear.set(Math.min(1, Math.max(0, travelled / (PACK_W * 0.9)))); } function onPackPointerUp() { const state = drag.current; drag.current = null; if (!state?.moved || phase !== "sealed") return; // Past halfway the tear finishes by itself; otherwise the strip settles back. if (tear.get() > 0.5) open(); else animate(tear, 0, spring.snappy); } function nextPack() { if (phase !== "revealed") return; setSelected(null); setPhase("sweeping"); } function afterSweep() { if (phase !== "sweeping") return; const next = (index + 1) % packs.length; tear.jump(0); setRound((n) => n + 1); if (activePack === undefined) setInternal(next); onActivePackChange?.(next); focusPack.current = true; setPhase("sealed"); } useEffect(() => { if (phase === "sealed" && focusPack.current) { focusPack.current = false; packRef.current?.focus({ preventScroll: true }); } }, [phase]); function select(card: BurstPackCard, trigger: HTMLElement) { opener.current = trigger; setFlipped(false); setSelected(card); onCardSelect?.(card); // The enlarged card turns over to its back a beat after it opens. later(() => setFlipped(true), reduceMotion ? 0 : 420); } if (!pack) return null; const sealed = phase === "sealed" || phase === "opening"; const flying = !reduceMotion; return ( <div className={cn("@container relative w-full", className)} {...props}> <div ref={stageRef} style={{ height: STAGE_H }} className="relative mx-auto w-full max-w-[560px] overflow-hidden"> {/* The pack: a foil pouch with crimped seams that bobs over its shadow. */} <AnimatePresence> {sealed ? ( <motion.div key={`pack-${index}-${round}`} style={{ top: PACK_TOP, width: PACK_W, height: PACK_H, marginLeft: -PACK_W / 2, "--pack": pack.color } as CSSProperties & Record<"--pack", string>} initial={reduceMotion ? { opacity: 0 } : { y: -PACK_TOP - PACK_H - 20, opacity: 1, rotate: -6 }} animate={{ y: 0, opacity: 1, rotate: 0 }} exit={reduceMotion ? { opacity: 0 } : { y: 90, opacity: 0, rotate: 5, transition: { duration: duration.slow, ease: ease.inOut } }} transition={reduceMotion ? { duration: duration.base } : spring.bouncy} className="absolute left-1/2 z-10" > <motion.span aria-hidden="true" animate={reduceMotion ? undefined : { scaleX: [1, 0.86, 1], opacity: [0.32, 0.22, 0.32] }} transition={{ duration: 3, repeat: Infinity, ease: "easeInOut" }} className="absolute -bottom-5 left-1/2 -ml-12 h-3 w-24 rounded-[50%] bg-black blur-[5px]" /> <motion.div animate={reduceMotion ? undefined : { y: [0, -3, 0] }} transition={{ duration: 3, repeat: Infinity, ease: "easeInOut" }} className="size-full"> <motion.button ref={packRef} type="button" aria-label={`Open pack: ${pack.label} series`} disabled={phase !== "sealed"} onClick={() => { if (tear.get() < 0.02) open(); }} onPointerDown={onPackPointerDown} onPointerMove={onPackPointerMove} onPointerUp={onPackPointerUp} onPointerCancel={onPackPointerUp} style={{ x: shake }} className="group/pack relative size-full cursor-pointer touch-none outline-none select-none focus-visible:[filter:drop-shadow(2px_0_0_var(--ring))_drop-shadow(-2px_0_0_var(--ring))_drop-shadow(0_2px_0_var(--ring))_drop-shadow(0_-2px_0_var(--ring))]" > {/* Body, with the dark mouth that shows once the strip is gone. */} <span className="absolute inset-x-0 top-[14px] bottom-0 overflow-hidden bg-(--pack) shadow-[inset_0_0_0_1px_rgb(255_255_255/0.25)]" style={{ clipPath: ZIGZAG_BOTTOM }}> <span className="absolute inset-0 bg-[linear-gradient(100deg,rgb(255_255_255/0.28),transparent_30%,rgb(0_0_0/0.12)_62%,rgb(255_255_255/0.2))]" /> <span className="absolute inset-x-0 top-0 h-2 bg-black/55" /> <motion.span aria-hidden="true" animate={reduceMotion ? undefined : { x: ["-160%", "260%"] }} transition={{ duration: 3.4, repeat: Infinity, ease: "easeInOut", repeatDelay: 0.6 }} className="absolute inset-y-0 left-0 w-1/2 -skew-x-12 bg-[linear-gradient(90deg,transparent,rgb(255_255_255/0.45),transparent)]" /> <span className="absolute top-5 left-2.5 rounded-[3px] bg-white/90 px-1.5 py-0.5 text-[8px] leading-none font-bold tracking-[0.08em] text-black uppercase">{brandLabel}</span> <span className="absolute inset-0 grid place-items-center">{pack.emblem ?? <StarEmblem />}</span> <span className="absolute inset-x-0 bottom-4 text-center text-[10px] font-semibold tracking-[0.14em] text-white uppercase [text-shadow:0_1px_1px_rgb(0_0_0/0.35)]">{pack.label}</span> </span> {/* The tear strip: its attached part is clipped away as the tear travels. */} <motion.span aria-hidden="true" style={{ clipPath: stripClip }} className="absolute inset-x-0 top-0 block h-[22px]"> <span className="absolute inset-0 bg-(--pack) brightness-110" style={{ clipPath: ZIGZAG_TOP }}> <span className="absolute inset-0 bg-[repeating-linear-gradient(90deg,rgb(255_255_255/0.22)_0_2px,transparent_2px_5px)]" /> </span> </motion.span> <motion.span aria-hidden="true" style={{ x: curlX, opacity: curlOpacity }} className="absolute -top-2 left-0 block h-7 w-3.5 rotate-[24deg] rounded-[3px] bg-[linear-gradient(90deg,rgb(255_255_255/0.95),var(--pack))] shadow-[0_2px_4px_rgb(0_0_0/0.3)]" /> </motion.button> </motion.div> </motion.div> ) : null} </AnimatePresence> <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 z-20 size-full [image-rendering:pixelated]" /> {/* The row: cards land one by one, slightly turned and overlapping. */} <AnimatePresence onExitComplete={afterSweep}> {phase === "revealed" ? ( <motion.ul key={`row-${index}-${round}`} exit={{ opacity: 1 }} style={{ top: ROW_TOP }} className="absolute inset-x-0 z-30 flex flex-wrap justify-center gap-y-2 px-2"> {deal.map((entry, slot) => ( <RevealedCard key={entry.card.id} card={entry.card} slot={slot} count={deal.length} tilt={entry.tilt} fly={flying} onSelect={(trigger) => select(entry.card, trigger)} /> ))} </motion.ul> ) : null} </AnimatePresence> <AnimatePresence> {phase === "revealed" ? ( <motion.div key="next" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0, transition: { delay: reduceMotion ? 0.2 : 0.12 + deal.length * 0.07 + 0.62 + 0.6, duration: duration.base, ease: ease.out } }} exit={{ opacity: 0, transition: { duration: duration.quick } }} className="absolute inset-x-0 bottom-6 z-30 flex justify-center" > <button ref={nextRef} type="button" onClick={nextPack} className="inline-flex h-11 cursor-pointer items-center gap-2 rounded-full bg-foreground px-5 text-sm font-semibold text-background shadow-md outline-none transition-transform duration-150 active:scale-[0.97] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" > <RotateCw aria-hidden="true" className="size-4" strokeWidth={2.25} /> Open another </button> </motion.div> ) : null} </AnimatePresence> </div> <span role="status" aria-live="polite" className="sr-only"> {announcement} </span> <Dialog.Root open={selected !== null} onOpenChange={(next) => !next && setSelected(null)}> <AnimatePresence> {selected ? ( <Dialog.Portal forceMount> <Dialog.Overlay asChild forceMount> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: duration.quick }} className="fixed inset-0 z-50 bg-black/55 backdrop-blur-[2px]" /> </Dialog.Overlay> <Dialog.Content asChild forceMount aria-describedby={undefined} onCloseAutoFocus={(event) => { event.preventDefault(); opener.current?.focus({ preventScroll: true }); }} > <motion.div initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.86 }} animate={{ opacity: 1, scale: 1 }} exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.92 }} transition={reduceMotion ? { duration: duration.quick } : spring.smooth} className="fixed inset-0 z-50 m-auto flex h-fit w-[min(240px,70vw)] flex-col items-center gap-4 outline-none" > <Dialog.Title className="sr-only">{selected.name}</Dialog.Title> <div className="aspect-[5/7] w-full text-[34px] [perspective:900px]"> <motion.div animate={{ rotateY: flipped ? 180 : 0 }} transition={reduceMotion ? { duration: 0 } : { duration: 0.6, ease: ease.inOut }} className="relative size-full [transform-style:preserve-3d]" > <CardFace card={selected} className="[backface-visibility:hidden]"> {selected.rarity === "holo" ? <span aria-hidden="true" style={{ backgroundImage: FOIL, backgroundSize: "260% 100%", backgroundPosition: "35% 50%" }} className="absolute inset-0 mix-blend-overlay" /> : null} </CardFace> <div className="absolute inset-0 flex [transform:rotateY(180deg)] flex-col rounded-[10%/7%] border-[3px] border-white bg-zinc-900 p-5 text-left text-base text-white [backface-visibility:hidden]"> <p className="font-mono text-[10px] tracking-[0.14em] text-white/70 uppercase">{selected.rarity === "holo" ? "Holo" : "Common"}</p> <p className="mt-2 text-xl font-semibold tracking-tight">{selected.name}</p> {selected.stat ? <p className="mt-1 font-mono text-xs text-white/80">{selected.stat}</p> : null} {selected.description ? <p className="mt-4 text-sm leading-5 text-white/90">{selected.description}</p> : null} <p className="mt-auto font-mono text-[10px] tracking-[0.14em] text-white/70 uppercase">{pack.label} series</p> </div> </motion.div> </div> <div className="flex gap-2"> <button type="button" onClick={() => setFlipped((value) => !value)} className="inline-flex h-10 cursor-pointer items-center gap-2 rounded-full bg-white px-4 text-sm font-medium text-zinc-900 outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-black" > <RotateCw aria-hidden="true" className="size-4" /> Flip card </button> <Dialog.Close className="grid size-10 cursor-pointer place-items-center rounded-full bg-white/15 text-white outline-none hover:bg-white/25 focus-visible:ring-2 focus-visible:ring-white" aria-label="Close"> <X aria-hidden="true" className="size-4" /> </Dialog.Close> </div> </motion.div> </Dialog.Content> </Dialog.Portal> ) : null} </AnimatePresence> </Dialog.Root> </div> ); }
Usage
import { BurstPackOpening, type BurstPack } from "@/components/ui/burst-pack-opening/burst-pack-opening";
const packs: BurstPack[] = [
{
id: "cobalt",
label: "Cobalt",
color: "#1d4ed8",
cards: [
{ id: "c1", name: "Bounder", stat: "Bounce 7", art: <BounderArt />, ground: "#ffd9c2" },
{ id: "c2", name: "Halo Nine", stat: "Orbit 9", art: "/cards/halo-nine.webp", rarity: "holo" },
],
},
];
export function Packs() {
return <BurstPackOpening packs={packs} onOpen={(pack, cards) => save(pack.id, cards)} />;
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
packs* | BurstPack[] | – | Packs to open, in order. After the last one the first comes around again. |
activePack | number | – | Controlled index of the pack on stage. |
defaultActivePack | number | 0 | Index of the first pack when uncontrolled. |
onActivePackChange | (index: number) => void | – | Called when the next pack drops in. |
onOpen | (pack: BurstPack, cards: BurstPackCard[]) => void | – | Called when a pack is torn open, with its cards in the order they landed. |
onCardSelect | (card: BurstPackCard) => void | – | Called when a revealed card is opened. |
seed | number | 229 | Seed for the card order, landing angles and confetti, so every visitor sees the same opening. |
burst | boolean | true | Fire the pixel rays and confetti. |
brandLabel | string | "Series" | Small brand text on the pack's label. |
Also accepts every native <div> attribute, including ref and className.
- Drag from either top corner to tear: the strip peels with the pointer and finishes by itself past halfway. A click or key press runs the same tear in half a second.
- The pack and the cards are DOM with CSS 3D, driven by Motion values. Rays and confetti are one low-res canvas between them, drawn at 4 screen pixels per pixel with smoothing off; its loop runs only while the burst is on screen.
- Card order, landing angles and confetti come from seed, the pack and how many packs have been opened, so server and client agree and a pack that comes around again deals a new order.
- Cards with rarity "holo" get the foil from Foil Card Drift, here following the pointer. Hover lifts and tilts a card up to 8°.
- art can be a React node (SVG scales cleanly) or an image URL. All demo art is original SVG drawn in code.
- In narrow containers the five cards wrap into two rows. The stage has a fixed height of 400px.
Accessibility
- The pack is a button named "Open pack: Cobalt series". Enter, Space or a click tears it open; dragging along the top edge is an extra, not a requirement.
- A polite live region announces the reveal once: "5 cards revealed: …" with the card names in landing order.
- Each card is a button. It opens a dialog that traps focus, is titled with the card's name, closes with Escape or its Close button and returns focus to the card.
- The card's back (name, stat, description and rarity) is real text, and a Flip card button turns it from the keyboard.
- When the next pack drops in, focus moves to it.
- With reduced motion enabled there are no rays, confetti or flight: the pack crossfades into the row and the enlarged card swaps faces without turning.