Event ticket
Components
A pass that feels like your event. Compose the cover, content, and attendee details, from a playful workshop ticket to a landscape conference pass. The QR code, reflection, and gentle motion are built in.
The live previews use React. Install includes the selected platform’s native source and setup.
One night at the Printworks
Night
shift.
Live music. Late into the night.- When
- 26 June 2027Doors 19:00 BST
- Where
- The PrintworksLondon
- Section
- Balcony
- Row
- C
- Seat
- 12
Design · code · craft
Off
script.
Assembly / 2027- When
- 19 August 202709:30–18:00 CEST
- Where
- The FoundryBerlin
A change of scenery
Lisbon/ EA 207
- Gate
- C14 · London Heathrow
- Seat
- 14A · Window
Playground
Try it your way.
A day for good ideas.
- Where
- Studio 02Chapter, Cardiff
- When
- 18 June 2027
09:00–18:00 BST
Admit one
Daniel Paul
daniel@example.com
All-day pass
FF-2027-0042
Your own React content, inside the same ticket.
Installation
ReactThe npm package is being prepared for its first release. For now, download the files and install the dependencies below.
npm install motion@14.9.0 qrcode.react@4.2.0Usage
'use client'
import { EventTicket, TicketDetails, TicketDetail, TicketDisclosure } from './EventTicket'
export default function RegistrationComplete() {
return <EventTicket
brand="Form & Field"
event="A day for good ideas."
attendee="Daniel Paul"
email="daniel@example.com"
ticketId="FF-2027-0042"
qrValue="https://example.com/check-in/demo-0042"
admission="All-day pass"
brandColor="#3453eb"
brandMark={<svg width="28" height="28" viewBox="0 0 28 28" fill="none" aria-hidden="true"><path d="M4 22V6h9v7H4m9 9V6h11M13 14h9" stroke="currentColor" strokeWidth="2" /></svg>}
>
{/* Children replace the default date/venue area. Add any React content. */}
<div style={{ display: 'grid', gap: 8 }}>
<span style={{ fontSize: 10, color: 'var(--ticket-secondary)' }}>Your selected workshop</span>
<strong style={{ fontSize: 20, lineHeight: 1.25, fontWeight: 550 }}>Designing with good constraints</strong>
</div>
<TicketDetails>
<TicketDetail label="When"><time dateTime="2027-06-18T09:00:00+01:00">18 June · 09:00 BST</time></TicketDetail>
<TicketDetail label="Where">Studio 02, Chapter, Cardiff</TicketDetail>
</TicketDetails>
<TicketDisclosure label="Programme">
<ul style={{ paddingLeft: 16, margin: 0, fontSize: 12, lineHeight: 1.8 }}><li>09:00 — Coffee & introductions</li><li>09:30 — A short talk, then make something</li><li>12:00 — Share what you’ve made</li></ul>
</TicketDisclosure>
</EventTicket>
}React 18.2+ or React 19 with matching react-dom, TypeScript, and a bundler with CSS Modules support (Vite, Next.js, or Astro).
Installation
ReactThe npm package is being prepared for its first release. For now, download the files and install the dependencies below.
npm install motion@14.9.0 qrcode.react@4.2.0Usage
'use client'
import { EventTicket, TicketDetails, TicketDetail, TicketDisclosure } from './EventTicket'
export default function RegistrationComplete() {
return <EventTicket
brand="Form & Field"
event="A day for good ideas."
attendee="Daniel Paul"
email="daniel@example.com"
ticketId="FF-2027-0042"
qrValue="https://example.com/check-in/demo-0042"
admission="All-day pass"
brandColor="#3453eb"
brandMark={<svg width="28" height="28" viewBox="0 0 28 28" fill="none" aria-hidden="true"><path d="M4 22V6h9v7H4m9 9V6h11M13 14h9" stroke="currentColor" strokeWidth="2" /></svg>}
>
{/* Children replace the default date/venue area. Add any React content. */}
<div style={{ display: 'grid', gap: 8 }}>
<span style={{ fontSize: 10, color: 'var(--ticket-secondary)' }}>Your selected workshop</span>
<strong style={{ fontSize: 20, lineHeight: 1.25, fontWeight: 550 }}>Designing with good constraints</strong>
</div>
<TicketDetails>
<TicketDetail label="When"><time dateTime="2027-06-18T09:00:00+01:00">18 June · 09:00 BST</time></TicketDetail>
<TicketDetail label="Where">Studio 02, Chapter, Cardiff</TicketDetail>
</TicketDetails>
<TicketDisclosure label="Programme">
<ul style={{ paddingLeft: 16, margin: 0, fontSize: 12, lineHeight: 1.8 }}><li>09:00 — Coffee & introductions</li><li>09:30 — A short talk, then make something</li><li>12:00 — Share what you’ve made</li></ul>
</TicketDisclosure>
</EventTicket>
}Setup with AI agent
Give your agent this link. It includes every file, dependency, and setup step.
Go to https://danielpaul.me/ui/event-ticket/agent.md for the source, dependencies, and setup instructions, then add the Event ticket component to this project.Source files
Keep these files together and follow the README for your project’s setup.
'use client'
import { useEffect, useId, useRef, useState, type CSSProperties, type ReactElement, type ReactNode, type PointerEvent, type HTMLAttributes } from 'react'
import { motion, useMotionValue, useSpring, useTransform } from 'motion/react'
import { QRCodeSVG } from 'qrcode.react'
import styles from './EventTicket.module.css'
import { ticketIdle, ticketSpring, ticketExit, ticketTiltSpring, ticketReflectionSpring } from './motion'
export interface TicketDetailsProps extends HTMLAttributes<HTMLDListElement> { children?: ReactNode }
export interface TicketDetailProps extends HTMLAttributes<HTMLDivElement> { label: ReactNode; children?: ReactNode }
export interface TicketDisclosureProps {
label: string
children: ReactNode
defaultOpen?: boolean
className?: string
}
export interface EventTicketClasses { cover?: string; content?: string; stub?: string; attendee?: string }
function useTicketReducedMotion(): boolean {
const [reducedMotion, setReducedMotion] = useState(true)
useEffect(() => {
const preference = matchMedia('(prefers-reduced-motion: reduce)')
const update = () => setReducedMotion(preference.matches)
update()
preference.addEventListener('change', update)
return () => preference.removeEventListener('change', update)
}, [])
return reducedMotion
}
export function TicketDetails({ children, className = '', ...props }: TicketDetailsProps): ReactElement {
return <dl {...props} className={`${styles.details} ${className}`}>{children}</dl>
}
export function TicketDetail({ label, children, className = '', ...props }: TicketDetailProps): ReactElement {
return <div {...props} className={className}><dt>{label}</dt><dd>{children}</dd></div>
}
/** Compose an animated agenda, entry instructions, or other optional ticket content. */
export function TicketDisclosure({ label, children, defaultOpen = false, className = '' }: TicketDisclosureProps): ReactElement {
const [open, setOpen] = useState(defaultOpen)
const [settledClosed, setSettledClosed] = useState(!defaultOpen)
const id = useId()
const reducedMotion = useTicketReducedMotion()
const transition = open ? { type: 'spring' as const, ...ticketSpring } : ticketExit
return <div className={`${styles.disclosure} ${className}`}>
<motion.button id={`${id}-trigger`} type="button" className={styles.disclosureTrigger}
aria-expanded={open} aria-controls={`${id}-panel`} onClick={() => { setSettledClosed(false); setOpen((value) => !value) }}
initial="rest" animate="rest" whileHover="hover" whileTap="press"
variants={{ rest: { scale: 1 }, press: { scale: reducedMotion ? 1 : 0.985 } }} transition={ticketSpring}>
<motion.span className={styles.disclosureHighlight} aria-hidden="true"
variants={{ rest: { opacity: 0 }, hover: { opacity: 0.05 }, press: { opacity: 0.08 } }} transition={ticketSpring} />
<span>{label}</span>
<span className={styles.disclosureIcon} aria-hidden="true">
<span className={styles.disclosureIconLine} />
<motion.span className={`${styles.disclosureIconLine} ${styles.disclosureIconVertical}`} initial={false}
animate={{ scaleY: open ? 0 : 1, opacity: open ? 0 : 1 }}
transition={{ ...transition, ...(reducedMotion ? { scaleY: { duration: 0 } } : {}) }} />
</span>
</motion.button>
{/* Keep the region mounted for stable IDs, but remove closed content from
keyboard/accessibility navigation immediately, including during exit. */}
<motion.div id={`${id}-panel`} role="region" aria-labelledby={`${id}-trigger`}
aria-hidden={!open} hidden={settledClosed} className={styles.disclosurePanel}
// Set the native attribute directly: React 18 and 19 serialize inert differently.
ref={(element) => { element?.toggleAttribute('inert', !open) }}
initial={false} animate={{ height: open ? 'auto' : 0, opacity: open ? 1 : 0 }}
transition={{ ...transition, ...(reducedMotion ? { height: { type: 'tween', duration: 0 } } : {}) }}
onAnimationComplete={() => setSettledClosed(!open)}>
<motion.div className={styles.disclosureContent} initial={false}
animate={{ y: open || reducedMotion ? 0 : -4 }} transition={reducedMotion ? { duration: 0 } : transition}>
{children}
</motion.div>
</motion.div>
</div>
}
export interface EventTicketProps {
brand: string
event: string
attendee: string
ticketId: string
/** An opaque check-in token or URL, supplied by your registration system. */
qrValue: string
date?: string
time?: string
venue?: string
dateTime?: string
email?: string
admission?: string
brandColor?: string
logo?: string
appearance?: 'light' | 'dark'
tilt?: boolean
reflection?: boolean
idleMotion?: boolean
/** Replaces the default date/venue body. Pass null to omit it. */
children?: ReactNode
/** Replaces the branded cover; event still supplies the accessible label. */
cover?: ReactNode
/** Decorative artwork behind the default cover. Null removes the artwork. */
artwork?: ReactNode
brandMark?: ReactNode
/** Replaces the attendee column while preserving the built-in QR. */
attendeeContent?: ReactNode
layout?: 'portrait' | 'landscape'
classes?: EventTicketClasses
className?: string
style?: CSSProperties
}
function brandPalette(color: string): { color: string; ink: string } {
const hex = /^#[\da-f]{6}$/i.test(color) ? color : '#3453eb'
const rgb = [1, 3, 5].map((offset) => parseInt(hex.slice(offset, offset + 2), 16) / 255)
const linear = rgb.map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4)
const luminance = linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722
return { color: hex, ink: luminance > 0.179 ? '#000000' : '#ffffff' }
}
/** A presentational registration pass. Issuing and validating tickets belongs to your app. */
export function EventTicket({
brand, event, attendee, ticketId, qrValue, date, time, venue, dateTime,
email, admission = 'General admission', brandColor = '#3453eb', logo,
appearance = 'light', tilt = true, reflection = true, idleMotion = true, className = '', style,
children, cover, artwork, brandMark, attendeeContent, layout = 'portrait', classes = {},
}: EventTicketProps): ReactElement {
const titleId = useId()
const container = useRef<HTMLDivElement>(null)
const face = useRef<HTMLDivElement>(null)
const idleSurface = useRef<HTMLDivElement>(null)
const pointerInside = useRef(false)
const focusInside = useRef(false)
const syncIdle = useRef<(() => void) | null>(null)
const reducedMotion = useTicketReducedMotion()
const pointerX = useMotionValue(0)
const pointerY = useMotionValue(0)
const rotateX = useSpring(pointerX, ticketTiltSpring)
const rotateY = useSpring(pointerY, ticketTiltSpring)
const pointerActive = useMotionValue(0)
const engagement = useSpring(pointerActive, ticketReflectionSpring)
const reflectionX = useTransform(rotateY, [-8, 8], ['24%', '-24%'])
const reflectionY = useTransform(rotateX, [-8, 8], ['-14%', '14%'])
const reflectionAngle = useTransform(rotateY, [-8, 8], [-12, 12])
const reflectionOpacity = useTransform(engagement, [0, 1], [0, 0.72])
const idleReflectionOpacity = useTransform(engagement, [0, 1], [1, 0])
const animated = tilt && !reducedMotion
const palette = brandPalette(brandColor)
// Keep the QR readable at this physical size, and never silently truncate a token.
const validCode = qrValue.trim().length > 0 && new TextEncoder().encode(qrValue).length <= 512
useEffect(() => {
pointerX.set(0); pointerY.set(0); pointerActive.set(0)
}, [tilt, reflection, reducedMotion, pointerX, pointerY, pointerActive])
useEffect(() => {
if (!reflection || !container.current || !face.current) return
const element = container.current
const front = face.current
// Both clipped halves sample the same-sized texture, so the reflection
// continues through the perforation instead of restarting on the stub.
const measure = () => {
element.style.setProperty('--ticket-height', `${element.offsetHeight}px`)
element.style.setProperty('--ticket-face-height', `${front.offsetHeight}px`)
element.style.setProperty('--ticket-width', `${element.offsetWidth}px`)
element.style.setProperty('--ticket-face-width', `${front.offsetWidth}px`)
}
measure()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(measure)
observer.observe(element); observer.observe(front)
return () => observer.disconnect()
}, [reflection])
useEffect(() => {
if (!idleMotion || !tilt || reducedMotion || !container.current || !idleSurface.current) return
const element = container.current
const options: KeyframeAnimationOptions = { duration: ticketIdle.duration, iterations: Infinity }
const animations = [idleSurface.current.animate(ticketIdle.float, options)]
element.querySelectorAll<HTMLElement>('[data-ticket-idle-reflection]').forEach((plane) => {
animations.push(plane.animate(ticketIdle.light, options))
})
animations.forEach((animation) => { animation.pause(); animation.currentTime = 0 })
let inView = false
let disposed = false
const sync = () => {
if (disposed) return
const play = inView && !document.hidden && !pointerInside.current && !focusInside.current
animations.forEach((animation) => { if (play) animation.play(); else animation.pause() })
}
syncIdle.current = sync
const observer = typeof IntersectionObserver === 'undefined' ? null : new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting
sync()
}, { threshold: 0 })
if (observer) observer.observe(element)
else { inView = true; sync() }
document.addEventListener('visibilitychange', sync)
return () => {
disposed = true
observer?.disconnect()
document.removeEventListener('visibilitychange', sync)
syncIdle.current = null
animations.forEach((animation) => animation.cancel())
}
}, [idleMotion, tilt, reflection, reducedMotion])
function reset() {
pointerX.set(0); pointerY.set(0); pointerActive.set(0)
pointerInside.current = false
syncIdle.current?.()
}
function followPointer(event: PointerEvent<HTMLDivElement>) {
if (!tilt || reducedMotion || focusInside.current || event.pointerType !== 'mouse') return
if (!pointerInside.current) { pointerInside.current = true; syncIdle.current?.() }
const rect = event.currentTarget.getBoundingClientRect()
const x = Math.max(-0.5, Math.min(0.5, (event.clientX - rect.left) / rect.width - 0.5))
const y = Math.max(-0.5, Math.min(0.5, (event.clientY - rect.top) / rect.height - 0.5))
pointerX.set(-y * 16)
pointerY.set(x * 16)
pointerActive.set(1)
}
// Static gradient textures move as compositor layers. Motion values avoid
// React renders on pointer movement; the optional idle loop is browser-native.
const lightStyle = { x: reflectionX, y: reflectionY, rotate: reflectionAngle }
const coatStyle = { opacity: animated ? reflectionOpacity : 0 }
const body = children !== undefined ? children : (date || time || venue) ? <TicketDetails>
{(date || time) && <TicketDetail label="When">{date && <time dateTime={dateTime}>{date}</time>}{time && <span className={styles.secondary}>{time}</span>}</TicketDetail>}
{venue && <TicketDetail label="Where">{venue}</TicketDetail>}
</TicketDetails> : null
return <div ref={container} className={`${styles.root} ${className}`} data-event-ticket data-appearance={appearance} data-layout={layout}
style={{ ...style, '--ticket-brand': palette.color, '--ticket-brand-ink': palette.ink } as CSSProperties}
onPointerEnter={followPointer} onPointerMove={followPointer} onPointerLeave={reset} onPointerCancel={reset}
onFocusCapture={() => { focusInside.current = true; pointerX.set(0); pointerY.set(0); pointerActive.set(0); syncIdle.current?.() }}
onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { focusInside.current = false; syncIdle.current?.() } }}
onPointerDown={(event) => { if (event.pointerType !== 'mouse') { pointerInside.current = true; syncIdle.current?.() } }}
onPointerUp={(event) => { if (event.pointerType !== 'mouse') reset() }}>
<div ref={idleSurface} className={styles.idle} data-ticket-idle>
<motion.article aria-labelledby={cover === undefined ? titleId : undefined} aria-label={cover !== undefined ? `${brand}: ${event}` : undefined} className={styles.ticket}
style={{ rotateX: animated ? rotateX : 0, rotateY: animated ? rotateY : 0, transformPerspective: 900 }}>
<div ref={face} className={styles.face}>
{cover !== null && <div className={`${styles.cover} ${cover !== undefined ? styles.customCover : ''} ${classes.cover ?? ''}`}>
{cover !== undefined ? cover : <>
{artwork === undefined ? <div className={styles.coverArt} aria-hidden="true"><span /><span /><span /></div> : artwork !== null && <div className={styles.customArtwork} aria-hidden="true">{artwork}</div>}
<div className={styles.brand}>
<span className={styles.mark} aria-hidden="true">{brandMark !== undefined ? brandMark : logo ? <img src={logo} alt="" width="28" height="28" /> : <svg width="26" height="26" viewBox="0 0 24 24" fill="none"><path d="M12 2v20M2 12h20M5 5l14 14M5 19 19 5" stroke="currentColor" strokeWidth="3" /></svg>}</span>
<span>{brand}</span>
</div>
<div className={styles.eventName}>
<span className={styles.confirmed}><svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="m3 8 3 3 7-7" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>You’re on the list</span>
<h3 id={titleId} className={styles.title}>{event}</h3>
</div>
</>}
<div className={styles.coverEdge} aria-hidden="true" />
</div>}
{body != null && <div className={`${styles.content} ${classes.content ?? ''}`}>{body}</div>}
{reflection && <>
{idleMotion && <motion.div className={styles.coat} style={{ opacity: animated ? idleReflectionOpacity : 0 }} aria-hidden="true"><div className={`${styles.reflection} ${styles.idleReflection}`} data-ticket-idle-reflection /></motion.div>}
<motion.div className={styles.coat} style={coatStyle} aria-hidden="true" data-ticket-reflection><motion.div className={styles.reflection} style={lightStyle} /></motion.div>
</>}
</div>
<div className={`${styles.stub} ${classes.stub ?? ''}`}>
<div className={`${styles.person} ${classes.attendee ?? ''}`}>
{attendeeContent !== undefined ? attendeeContent : <>
<p className={styles.label}>Admit one</p>
<p className={styles.attendee}>{attendee}</p>
{email && <p className={styles.email}>{email}</p>}
<p className={styles.admission}>{admission}</p>
<p className={styles.serial}>{ticketId}</p>
</>}
</div>
<div className={styles.checkIn}>
{validCode ? <QRCodeSVG xmlns="http://www.w3.org/2000/svg" data-ticket-qr value={qrValue} size={120} level="M" marginSize={4} bgColor="#ffffff" fgColor="#000000" role="img" title={`Check-in code for ${attendee}`} /> : <span className={styles.codeError} role="status">Check-in code unavailable</span>}
<span>Scan at check-in</span>
</div>
{reflection && <>
{idleMotion && <motion.div className={styles.coat} style={{ opacity: animated ? idleReflectionOpacity : 0 }} aria-hidden="true"><div className={`${styles.reflection} ${styles.idleReflection}`} data-ticket-idle-reflection /></motion.div>}
<motion.div className={styles.coat} style={coatStyle} aria-hidden="true" data-ticket-reflection><motion.div className={styles.reflection} style={lightStyle} /></motion.div>
</>}
</div>
</motion.article>
</div>
</div>
}.root {
--ticket-paper: #fff;
--ticket-ink: #17202d;
--ticket-secondary: #626b79;
--ticket-line: #d7dce4;
width: 100%;
max-width: 336px;
perspective: 1000px;
text-align: left;
font-family: inherit;
font-variation-settings: normal;
color-scheme: light;
-webkit-font-smoothing: antialiased;
container: event-ticket / inline-size;
}
.root[data-layout='landscape'] { max-width: 640px; }
.root[data-appearance='dark'] { --ticket-paper: #1c1f27; --ticket-ink: #f4f5f8; --ticket-secondary: #a8afbc; --ticket-line: #464c58; color-scheme: dark; }
.idle { transform-origin: center; }
.ticket { position: relative; margin: 0; color: var(--ticket-ink); filter: drop-shadow(0 12px 20px #12203b24); transform-origin: center; }
.root *, .root *::before, .root *::after { box-sizing: border-box; }
:where(.root) :where(p, h3, dl, dt, dd) { margin: 0; }
.face, .stub { position: relative; isolation: isolate; background: var(--ticket-paper); }
.face { border-radius: 18px 18px 0 0; overflow: hidden; mask-image: radial-gradient(circle 9px at 0 100%, transparent 98%, #000 100%), radial-gradient(circle 9px at 100% 100%, transparent 98%, #000 100%); mask-composite: intersect; }
.cover { position: relative; isolation: isolate; display: flex; flex-direction: column; justify-content: space-between; min-height: 230px; padding: 24px; overflow: hidden; color: var(--ticket-brand-ink); background: var(--ticket-brand); }
.brand { display: flex; align-items: center; gap: 10px; font-size: 14px; line-height: 20px; font-weight: 650; overflow-wrap: anywhere; }
.brand img { object-fit: contain; }
.brand img, .brand svg { flex: none; }
.mark { display: grid; place-items: center; flex: none; }
.customCover { display: block; }
.customArtwork { position: absolute; z-index: -1; inset: 0; overflow: hidden; pointer-events: none; }
.coverArt { position: absolute; z-index: -1; inset: -80px -120px 0 0; overflow: hidden; pointer-events: none; opacity: 0.1; transform: rotate(-24deg); }
.coverArt span { position: absolute; right: 0; top: -12px; width: 246px; height: 330px; border: 44px solid currentColor; border-radius: 50%; }
.coverArt span:nth-child(2) { right: 78px; }
.coverArt span:nth-child(3) { right: 156px; }
.coverEdge { position: absolute; pointer-events: none; inset: 0; border: 1px solid #ffffff26; border-radius: 18px 18px 0 0; box-shadow: inset 0 1px 0 #ffffff30; }
.eventName { position: relative; padding-top: 34px; }
.confirmed { display: flex; align-items: center; gap: 5px; margin-bottom: 10px; font-size: 11px; line-height: 16px; }
.title { max-width: 12ch; font-size: 32px; line-height: 1.06; letter-spacing: -0.045em; font-weight: 650; overflow-wrap: anywhere; text-wrap: balance; }
.content { display: grid; min-width: 0; gap: 16px; padding: 22px 24px 26px; overflow-wrap: anywhere; }
.details { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.details > div { min-width: 0; }
.details dt, .label { color: var(--ticket-secondary); font-size: 10px; line-height: 16px; }
.details dd { margin-top: 4px; font-size: 12px; line-height: 18px; font-weight: 550; overflow-wrap: anywhere; }
.secondary { display: block; color: var(--ticket-secondary); font-weight: 400; }
.disclosure { min-width: 0; }
.disclosureTrigger { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 36px; width: 100%; padding: 9px 11px; overflow: hidden; border: 1px solid var(--ticket-line); border-radius: 7px; background: transparent; color: inherit; text-align: left; font: inherit; font-size: 11px; line-height: 16px; cursor: pointer; }
.disclosureTrigger > span:not(.disclosureHighlight) { position: relative; }
.disclosureIcon { display: grid; place-items: center; flex: none; width: 14px; height: 14px; }
.disclosureIconLine { grid-area: 1 / 1; width: 10px; height: 1.5px; border-radius: 1px; background: currentColor; }
.disclosureIconVertical { width: 1.5px; height: 10px; transform-origin: center; }
.disclosureTrigger:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
.disclosureHighlight { position: absolute; inset: 0; pointer-events: none; background: currentColor; }
.disclosurePanel { overflow: hidden; }
.disclosureContent { padding-top: 14px; font-size: 11px; line-height: 17px; }
.stub { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 108px; align-items: center; gap: 10px; min-height: 166px; padding: 22px 20px 22px 24px; border-radius: 0 0 18px 18px; box-shadow: inset 0 -1px 0 #ffffff28; mask-image: radial-gradient(circle 9px at 0 0, transparent 98%, #000 100%), radial-gradient(circle 9px at 100% 0, transparent 98%, #000 100%); mask-composite: intersect; }
.stub::before { content: ''; position: absolute; left: 12px; right: 12px; top: 0; height: 1px; background: repeating-linear-gradient(90deg, var(--ticket-line) 0 4px, transparent 4px 8px); }
.person { min-width: 0; }
.root .attendee { margin-top: 4px; font-size: 16px; line-height: 21px; font-weight: 600; letter-spacing: -0.025em; overflow-wrap: anywhere; }
.email { margin-top: 3px !important; color: var(--ticket-secondary); font-size: 10px; line-height: 15px; overflow-wrap: anywhere; }
.admission { margin-top: 12px !important; font-size: 10px; line-height: 15px; overflow-wrap: anywhere; }
.serial { margin-top: 3px !important; color: var(--ticket-secondary); font-size: 10px; line-height: 15px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.checkIn { position: relative; z-index: 3; display: grid; justify-items: center; gap: 6px; }
.checkIn svg { display: block; width: 108px; height: 108px; border-radius: 8px; background: #fff; }
.checkIn > span { color: var(--ticket-secondary); font-size: 9px; line-height: 13px; text-align: center; }
.checkIn .codeError { display: grid; place-items: center; width: 108px; min-height: 108px; padding: 12px; border: 1px solid var(--ticket-line); border-radius: 8px; }
.coat { position: absolute; z-index: 2; inset: 0; overflow: hidden; pointer-events: none; border-radius: inherit; }
.reflection { position: absolute; left: calc(var(--ticket-width, 336px) * -0.65); top: calc(var(--ticket-height, 500px) * -0.65); width: calc(var(--ticket-width, 336px) * 2.3); height: calc(var(--ticket-height, 500px) * 2.3); pointer-events: none; background: linear-gradient(116deg, transparent 23%, #ffffff06 33%, #ffffff36 42%, #ffffff8c 46%, #ffffff26 48%, transparent 56%, #ffffff0d 68%, transparent 78%); }
.stub .reflection { top: calc(var(--ticket-height, 500px) * -0.65 - var(--ticket-face-height, 334px)); }
.idleReflection { opacity: 0; }
@container event-ticket (min-width: 520px) {
.root[data-layout='landscape'] .ticket { display: grid; grid-template-columns: minmax(0, 1fr) 176px; }
.root[data-layout='landscape'] .face { border-radius: 18px 0 0 18px; mask-image: radial-gradient(circle 9px at 100% 0, transparent 98%, #000 100%), radial-gradient(circle 9px at 100% 100%, transparent 98%, #000 100%); }
.root[data-layout='landscape'] .stub { grid-template-columns: 1fr; align-content: space-between; justify-items: start; gap: 24px; padding: 24px; border-radius: 0 18px 18px 0; mask-image: radial-gradient(circle 9px at 0 0, transparent 98%, #000 100%), radial-gradient(circle 9px at 0 100%, transparent 98%, #000 100%); }
.root[data-layout='landscape'] .stub::before { top: 12px; bottom: 12px; left: 0; right: auto; width: 1px; height: auto; background: repeating-linear-gradient(180deg, var(--ticket-line) 0 4px, transparent 4px 8px); }
.root[data-layout='landscape'] .stub .reflection { left: calc(var(--ticket-width, 640px) * -0.65 - var(--ticket-face-width, 464px)); top: calc(var(--ticket-height, 350px) * -0.65); }
}
@media (prefers-reduced-motion: reduce) { .ticket, .idle { transform: none !important; } .coat { display: none; } }
@media print { .ticket, .idle { transform: none !important; } .ticket { filter: none; } .coat { display: none; } .root { break-inside: avoid; } .cover { print-color-adjust: exact; -webkit-print-color-adjust: exact; } }// Fluid Functionalism's moderate tier, included so this download is standalone.
export const ticketSpring: { duration: number; bounce: number } = { duration: 0.16, bounce: 0 }
export const ticketExit: { duration: number } = { duration: 0.12 }
// useSpring's follower reaches the low-level spring generator directly in
// Motion 14.9. Use visualDuration (seconds) rather than a JSX duration token,
// which that path reads as milliseconds and settles almost immediately.
// The slow tier eases the whole card into its first pointer pose, without bounce.
export const ticketTiltSpring: { visualDuration: number; bounce: number } = { visualDuration: 0.24, bounce: 0 }
export const ticketReflectionSpring: { visualDuration: number; bounce: number } = { visualDuration: ticketSpring.duration, bounce: ticketSpring.bounce }
// Material choreography, deliberately slower than UI transitions. These run
// as browser-native transform/opacity animations, without a JavaScript loop.
export const ticketIdle: { duration: number; float: Keyframe[]; light: Keyframe[] } = {
duration: 8000,
float: [
{ transform: 'perspective(900px) translateY(0px) rotateX(0.2deg) rotateY(-0.6deg) rotateZ(-0.2deg)', easing: 'ease-in-out' },
{ transform: 'perspective(900px) translateY(-2px) rotateX(-0.6deg) rotateY(0.8deg) rotateZ(0.2deg)', easing: 'ease-in-out' },
{ transform: 'perspective(900px) translateY(0px) rotateX(0.2deg) rotateY(-0.6deg) rotateZ(-0.2deg)' },
],
light: [
{ transform: 'translate3d(14%, 2%, 0) rotate(-4deg)', opacity: 0.06, easing: 'ease-in-out' },
{ transform: 'translate3d(-12%, -2%, 0) rotate(3deg)', opacity: 0.24, easing: 'ease-in-out' },
{ transform: 'translate3d(14%, 2%, 0) rotate(-4deg)', opacity: 0.06 },
],
}'use client'
import { EventTicket, TicketDetails, TicketDetail, TicketDisclosure } from './EventTicket'
export default function RegistrationComplete() {
return <EventTicket
brand="Form & Field"
event="A day for good ideas."
attendee="Daniel Paul"
email="daniel@example.com"
ticketId="FF-2027-0042"
qrValue="https://example.com/check-in/demo-0042"
admission="All-day pass"
brandColor="#3453eb"
brandMark={<svg width="28" height="28" viewBox="0 0 28 28" fill="none" aria-hidden="true"><path d="M4 22V6h9v7H4m9 9V6h11M13 14h9" stroke="currentColor" strokeWidth="2" /></svg>}
>
{/* Children replace the default date/venue area. Add any React content. */}
<div style={{ display: 'grid', gap: 8 }}>
<span style={{ fontSize: 10, color: 'var(--ticket-secondary)' }}>Your selected workshop</span>
<strong style={{ fontSize: 20, lineHeight: 1.25, fontWeight: 550 }}>Designing with good constraints</strong>
</div>
<TicketDetails>
<TicketDetail label="When"><time dateTime="2027-06-18T09:00:00+01:00">18 June · 09:00 BST</time></TicketDetail>
<TicketDetail label="Where">Studio 02, Chapter, Cardiff</TicketDetail>
</TicketDetails>
<TicketDisclosure label="Programme">
<ul style={{ paddingLeft: 16, margin: 0, fontSize: 12, lineHeight: 1.8 }}><li>09:00 — Coffee & introductions</li><li>09:30 — A short talk, then make something</li><li>12:00 — Share what you’ve made</li></ul>
</TicketDisclosure>
</EventTicket>
}# Event ticket
A brandable React event ticket for your registration confirmation screen.
Includes a real SVG QR code, attendee details, a perforated stub, a glossy
finish, and subtle pointer-driven 3D tilt. Compose the cover, body, and attendee
column using your own React content.
## Install
Requires React 18.2+ or 19 with matching react-dom, TypeScript, and CSS Modules
(Vite, Next.js, or Astro). No Tailwind configuration or custom font is needed.
```sh
npm install motion@14.9.0 qrcode.react@4.2.0
```
Copy these files together into a folder such as `components/event-ticket/`:
- `EventTicket.tsx`
- `EventTicket.module.css`
- `motion.ts`
Import `EventTicket` from that folder. `Usage.tsx` is a complete example.
The CSS module is imported by the component; no global stylesheet is needed.
Use your existing React setup; for a new Vite project:
```sh
npm create vite@latest my-event -- --template react-ts
cd my-event
npm install
npm install motion@14.9.0 qrcode.react@4.2.0
```
For Next.js App Router, keep the supplied `'use client'` directive. Serializable
ticket props can be passed from a server page. In Astro, use `client:visible`
or `client:load` to enable tilt. Content and QR still render without JavaScript.
## Your registration flow
Render the component after your app has successfully registered the attendee.
Pass the issued ticket reference to `ticketId` and an opaque check-in token or
URL to `qrValue`. They are separate props on purpose. The demo URL is an
example, not a live entry ticket.
The QR encodes `qrValue` exactly, locally, without contacting a QR service.
Your backend must validate tokens, permissions, and duplicate check-ins. Do
not use the visible name, email, or ticket reference as proof of entry. Keep
personal information out of QR values; use a server-validated token instead.
Use short tokens. Empty values and values over 512 UTF-8 bytes display
“Check-in code unavailable” without truncating the value. QR output has a
four-module quiet zone and fixed black-on-white contrast in both appearances.
Test scanning at the actual size your app displays, especially if you shrink it.
## Branding and layout
Set `brand`, `event`, and an optional `logo` image URL. `brandColor` accepts a
six-digit hex color and picks black or white header text for contrast; invalid
values fall back to cobalt. `appearance="dark"` changes the paper and stub.
You control appearance explicitly, so it can follow your own theme provider.
The portrait ticket fills its parent up to 336px wide. `layout="landscape"`
allows up to 640px and puts the QR stub on the right when the component's own
container is at least 520px wide. Smaller containers use the portrait layout.
Text wraps; long names and email
addresses stay visible. Supply `date`, `time`, and `venue` as display strings;
include the event timezone in `time`. Optional `dateTime` is an ISO value for
the time element. Dates are not automatically parsed, avoiding server/client
timezone mismatches. `admission` defaults to “General admission”.
## Compose your content
Supply `children` to replace the default date/venue area. They can contain any
React content: an agenda, session selection, seat, message, or interactive
component. The body grows with the content; the QR and finish stay built in.
`date`, `time`, and `venue` are optional when using custom children.
```tsx
import { EventTicket, TicketDetails, TicketDetail } from './EventTicket'
<EventTicket
brand="Your company"
event="Your gathering"
attendee="Alex Morgan"
ticketId="PASS-042"
qrValue="your-issued-opaque-token"
>
<h3>Your selected workshop</h3>
<TicketDetails>
<TicketDetail label="When">18 June · 09:00 BST</TicketDetail>
<TicketDetail label="Room">Studio 02</TicketDetail>
</TicketDetails>
<p>Bring a notebook. We’ll bring the coffee.</p>
</EventTicket>
```
`TicketDetails` renders a two-column `dl`. `TicketDetail` renders a labelled
`dt`/`dd` pair inside a `div`. They accept `className`, standard HTML attributes,
and arbitrary React children. They are optional: use your own markup instead.
`Usage.tsx` includes a complete expandable programme example, with all styles.
Use the optional `TicketDisclosure` export for an animated section inside your
ticket. Supply a `label` string and any React `children`; `defaultOpen` defaults
to false, and `className` styles the wrapper. It includes the expanding panel,
hover/press feedback, a plus that animates into a minus, and linked accessible button/region
IDs. Closed content is inert and hidden from assistive technology. Reduced
motion removes expansion and transform animations while retaining the fade.
It is included in `EventTicket.tsx`, with no additional dependencies.
```tsx
import { TicketDisclosure } from './EventTicket'
<TicketDisclosure label="Your schedule">
<p>09:00 — Coffee and introductions</p>
<p>09:30 — Opening session</p>
</TicketDisclosure>
```
Additional slots:
- `cover`: replace the full branded cover, including its heading. The required
`event` prop still supplies the accessible name. Your content controls its
typography. Pass `null` to omit the cover.
- `brandMark`: replace just the mark in the default cover with React content.
Takes precedence over `logo`.
- `artwork`: decorative content behind the default cover's text. Pass `null`
to remove the default artwork. It is hidden from assistive technology.
- `attendeeContent`: replace the guest column, preserving the built-in QR.
- `classes`: `{ cover, content, stub, attendee }` class names for styling each
area. Combine with your own CSS or utility classes; no internals need editing.
Omitted slots keep their defaults. `children={null}` removes the body. If you
replace the guest column, include the guest/reference information you need in
your custom content; those fields still name the QR for accessibility.
`--ticket-paper`, `--ticket-ink`, `--ticket-secondary`, and `--ticket-line` are
available to your custom content for appearance-aware colors. `appearance`
sets light/dark defaults; branded cover art keeps its supplied colors.
Focus inside any custom button, input, or link pauses idle motion and pointer
tilt, so interactive content stays steady. Use normal semantic controls and
their accessible labels. The component does not intercept clicks or keyboard
events in your content.
## Motion and reflection
`tilt={false}` keeps the pass still. `idleMotion` defaults to true: an eight-second
cycle gently rocks the ticket by less than a degree and lifts it by two pixels,
with a soft reflection sweep to invite interaction. These are browser-native
transform/opacity animations, not a JavaScript frame loop. They pause off-screen
and in hidden tabs. Hover or touch pauses the idle pose; pointer control takes
over smoothly. Set `idleMotion={false}` for hover-only motion. The playground
includes an Idle motion switch.
Hover keeps the paused idle position and tilts around the ticket’s center,
without lifting or zooming the pass. A clear-coat reflection tracks its angle. Set
`reflection={false}` for a matte finish. The QR sits above the clear coat, so
the effect does not wash out its modules or quiet zone.
The reflection is made from static CSS gradient textures, animated only with
transforms and opacity. It needs no WebGL context, canvas, image texture,
additional package, or JavaScript frame loop. Pointer movement updates
Motion values, without rerendering React or rebuilding the QR code. A
ResizeObserver aligns the two clipped halves of the reflection only when the
ticket's dimensions change; it does not measure on every animation frame. Springs
stop scheduling work after settling. Leaving the pass resets the pointer tilt
and resumes the quiet idle invitation when enabled. Toggling motion or the
finish also resets the pointer effect.
Touch input does not steer the tilt, and pauses idle motion while touching the
pass. Reduced-motion
preferences and printing disable transforms. Motion springs use the included
Fluid Functionalism moderate tier. There is no JavaScript animation timer;
the visible idle loop runs natively and pauses when not needed.
This is a presentation component, with no registration, email delivery,
storage, analytics, check-in scanner, or wallet integration.
MIT licensed. Dependencies retain their own licenses.MIT License
Copyright (c) 2026 Daniel Paul
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.API
| Prop | Default | What it does |
|---|---|---|
brand / event | Required | Organiser name and the event title. Long titles wrap instead of being cut off. |
attendee / email | Required / optional | Guest name and optional email, shown on the check-in stub. |
ticketId / qrValue | Required | Human-readable reference and a separate check-in token or URL. The QR encodes exactly qrValue, with no network request. |
date / time / venue | — | Optional display labels for the default body. Include the event’s timezone in time. Omit these when composing your own children. |
dateTime | — | Optional ISO 8601 value for the semantic time element. Display dates are not parsed or reformatted. |
admission | General admission | Ticket type or access level. |
brandColor | #3453eb | Six-digit hex color. Header text switches between black and white for readable contrast. |
logo | — | Optional image URL for your brand mark. The built-in mark is used when omitted. |
children | Date / venue details | Any React content for the body: sessions, agendas, seat details, buttons, or your own components. Replaces the default details. Pass null to omit the body. |
cover | Branded event cover | Replace the entire cover with your own React content and typography. The event prop still names the ticket accessibly. Pass null to omit the cover. |
brandMark / artwork | Built-in mark / artwork | React slots within the default cover. brandMark replaces the logo; artwork is decorative behind the text. Pass artwork={null} to remove it. |
attendeeContent | Guest details | Replace the attendee column with your own content. The scannable QR and its label remain built in. |
layout | portrait | portrait or landscape. Landscape places the QR stub on the right when the component is at least 520px wide, and falls back to portrait in smaller containers. |
classes | {} | Optional cover, content, stub, and attendee class names for styling individual areas without editing the component. |
appearance | light | light or dark for the paper and stub. The branded event face keeps its own color. |
tilt | true | Pointer-driven 3D tilt around the ticket’s center, without hover lift or zoom. Touch input does not steer the tilt. Reduced-motion preferences and printing keep the ticket still; false also disables the idle invitation. |
reflection | true | A moving clear-coat highlight follows the tilt, giving the ticket a glossy plastic finish. The QR stays above the reflection for scanning. Set false for a matte pass. |
idleMotion | true | A gentle 8-second sway and soft reflection sweep invite interaction. Pauses during hover, touch, or focus inside custom content, off-screen, and in hidden tabs. Reduced motion disables it; set false for hover-only motion. |
className / style | — | Compose the outer container with your layout. Portrait fills its container up to 336px; landscape up to 640px. |
TicketDetails and TicketDetail are optional exported building blocks for a responsive two-column definition list. Use <TicketDetails><TicketDetail label="Room">Studio 02</TicketDetail></TicketDetails>, or bring your own markup. Both accept className and standard HTML attributes.
TicketDisclosure is an optional animated content section. Use <TicketDisclosure label="Programme">your agenda or other React content</TicketDisclosure>. Props: label (required string), children, defaultOpen (false), and className. The panel expands smoothly with button feedback and a plus that animates into a minus. Closed content is inert and hidden from assistive technology; reduced motion removes the expansion and transform animations. It ships in the same files with no additional dependencies.
This is a confirmation UI. Your application issues ticket references and validates check-in; the component does not register attendees or prevent duplicate entry. Use an opaque, server-validated token instead of putting personal information into a QR code.
QR codes render as SVG locally, with black modules, a white background, and a four-module quiet zone. Empty values or payloads over 512 UTF-8 bytes show an unavailable state instead of an invalid or silently truncated code. Keep tokens short and test scanning at your final displayed size.
Readable HTML and the QR code render on the server. No font, image service, site alias, Tailwind setup, or external QR endpoint is required. Supply a logo only if you want one.
The reflection uses static gradient textures with animated transforms and opacity. The idle invitation uses browser-native animations while visible, with no JavaScript frame loop. Pointer movement updates Motion values instead of React state. The idle pose pauses on hover for a smooth hand-off to pointer control. Focusing a button, checkbox, or other control inside the ticket pauses both idle and pointer tilt so it stays easy to use.
Free to use. License is MIT.