# Event ticket — agent installation guide

This document contains the complete distributable source, usage example,
installation requirements, and MIT license from Daniel Paul's UI Kit.
The HTML demo and ZIP download are generated from the same files.

## Install in an existing project

1. Target platform: React. Read the project's instructions and inspect its
   framework, supported OS/runtime, package manager, and component conventions.
   Reuse the existing app and compatible dependencies. Do not replace its framework.
2. Requirements: React 18.2+ or React 19 with matching react-dom, TypeScript, and a bundler with CSS Modules support (Vite, Next.js, or Astro).
   Dependency command: `npm install motion@14.9.0 qrcode.react@4.2.0`. Adapt it to the project's package manager and reuse compatible existing dependencies.
3. Copy every component file and the MIT license below together. A suggested
   location is `src/components/event-ticket/`; adapt to the project's conventions
   and preserve relative imports. The platform-specific README and usage example explain
   integration, resources, and accessibility.
4. Integrate the component where the user asks. If no location is specified,
   add a small example in an appropriate existing preview or screen and report
   where to find it. Connect real app state and persistence only where needed;
   do not claim that local preview edits have been saved remotely.
5. Preserve keyboard access, visible focus, reduced-motion behavior, and the
   license. Run the relevant type checks and build, and report any checks that
   could not run. Do not deploy or publish without the user's authorization.

## Complete files

## EventTicket.tsx

```tsx
'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>
}
```

## EventTicket.module.css

```css
.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; } }
```

## motion.ts

```ts
// 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 },
  ],
}
```

## Usage.tsx

```tsx
'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 &amp; introductions</li><li>09:30 — A short talk, then make something</li><li>12:00 — Share what you’ve made</li></ul>
    </TicketDisclosure>
  </EventTicket>
}
```

## README.md

````text
# 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.
````

## LICENSE.txt

```text
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.
```
