Ghost pass
NewAn invite page: a glossy pastel ticket with a glowing ghost flies in, rises, tilts to the pointer and spins, above a code to copy.
ghost-pass/ghost-pass.tsxYou've been sent a Ghost Pass
Skip the Veil waitlist by downloading the app and redeeming your code.
On a computer? Copy your code, then open Veil on your phone.
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- The page an invite or referral link lands on: one code to redeem, and one or two ways in.
- A launch or waitlist moment that should feel like receiving something: a ticket to hold, turn over and keep.
- A code that is typed on another device: the pill keeps it large and selectable, and Copy code says when it is copied.
- A playful brand whose mascot can live on the pass, drawn in SVG with no image files to load.
When not to use
- Codes people type under pressure, like one-time passwords or 2FA. Use a plain code input with paste support.
- A list of several invites or tickets. Use a table or cards, each with its own copy button.
- A real boarding pass or event ticket that must scan. Use a wallet pass with a barcode.
- A sober product (banking, health, admin). The flying, floating, spinning pass reads as a game; use a simple card.
- Inside a dialog or a narrow column. It is a whole page section, scaled from 880 px down to about 580 px.
You can add this component with the shadcn CLI or by hand:
Into components/ghost-pass of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/ghost-pass.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { GhostPass } from './ghost-pass';export function InvitePage({ code }: { code: string }) { return ( <GhostPass code={code} appName="Veil" downloadHref="/download" waitlistHref="/waitlist" autoplay={false} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
The page an invite code lands on. A bent, glossy ticket with a glowing ghost flies up into the middle of the stage as rays open behind it, then rises to its place while the headline comes in word by word, then the code and the two ways in. The ticket floats, leans towards the pointer with its shine sliding the other way, and spins round to show its back when dragged. Copy code copies the code and says so.
- The ticket is one SVG drawn from a few numbers: its outlines are computed once, the top and bottom edges bowing upwards as one quadratic curve each (exact for a parabola), so the card looks bent round a vertical axis. A darker copy of each outline, a few units lower, is its thickness; the stub is a separate outline whose left edge is a row of small elliptical scallops.
- The art is layered gradients clipped to the body: a pink-to-periwinkle sky, slow rays, two coins with a rim, and the ghost, a dome clipped over a radial gradient with a blurred white copy behind it for the glow and a lavender fade at its foot.
- The entrance is CSS animations keyed by data-phase: the pass flies in from below, small and steeply turned, to the middle of the stage (an overshooting curve), rays open and turn behind it, then after 1.25 s it rises to its place in 0.42 s while the wash grows down the stage. The headline words then fade, unblur and lift 50 ms apart, and the lines below follow 110 ms apart.
- The float, the rays, the bobbing coins and the blink are endless CSS animations, paused offscreen or in a hidden tab, and off under reduced motion.
- The lean and the spin are springs run frame by frame only while something moves, written as one rotateX/rotateY transform on the card and two custom properties that slide the shine. A drag spins it; on release the throw picks the whole turn to land on, and a softer spring brings it there with a small bounce. The back is the other face of the same 3D box, mirrored so the stub is on the correct side.
- Copy code writes to the clipboard, and both labels share one grid cell so the button never changes width as Copy code turns into Copied.
- While on screen and untouched, a demo pointer sweeps over the pass (which leans after it, read from the pointer’s own animation each frame), flicks it round and presses Copy code without touching the clipboard. Any real input hands control to the reader.
- Every size is a multiple of one unit that follows the stage’s width (a container query), so the page scales as a whole down to about 580 px and below that the headline wraps instead.
| Prop | Type | Default | Description |
|---|---|---|---|
code | string | 'K4WX2PQN' | The invite code shown in the pill, and copied. |
appName | string | 'Veil' | The issuer’s name: on the stub and the back of the pass, and in the copy. |
title | string | 'Ghost Pass' | The name of the pass, highlighted at the end of the headline. |
downloadHref | string | — | Where Download the app leads; without it, a plain button. |
waitlistHref | string | — | Where Join the web waitlist leads; without it, a plain button. |
onCopy | (code: string) => void | — | Called when the reader copies the code (also when the clipboard refuses). |
intro | boolean | true | Plays the entrance the first time it comes on screen; false shows it settled at once. |
autoplay | boolean | true | While on screen and untouched, a demo pointer tilts and spins the pass and presses Copy code. |
defaultCopied | boolean | false | Opens with the button saying Copied, until it is pressed. Turns the demo off. |
defaultFace | 'front' | 'back' | 'front' | Opens turned to its back. A drag, a tap or a key turns it to face front. Turns the demo off. |
defaultLean | { x: number; y: number } | — | Opens leaning as if the pointer were at x, y (each -1 to 1 from the pass’s centre), its shine slid the other way. Turns the demo off. |
colorScheme | 'light' | 'dark' | 'system' | — | Light or dark colours, or the reader’s; without it, the page’s color-scheme. |
className | string | — | Added to the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode,} from 'react';import { DemoPointer, runDemo, type Point } from './demo-pointer';import { PassBack, PassFront } from './pass-art';import { useAutoplay } from './use-autoplay';import styles from './ghost-pass.module.css';/* * Ghost pass: the page an invite code lands on. A glossy pastel ticket with a glowing ghost floats * above the words "You've been sent a Ghost Pass", the code in a pill with a Copy button, and the * two ways in.// Copyright (c) 2026 Bruno Delorence. MIT License.import { useId } from 'react';import styles from './ghost-pass.module.css';/* * The pass, drawn: a ticket bent a little around a vertical axis, so its top and bottom edges bow * upwards. The body holds the art (a pastel sky with rays, a glowing ghost, two coins); past a * thin gap, the stub carries the issuer's mark, its left edge scalloped where it would tear off. * * Every outline is computed once here from a few numbers, in the drawing's own units (290 wide). * A darker copy of each outline, a few units lower, is the card's thickness. The rays, the coins, * the ghost's blink and the shine are groups the stylesheet moves by transforms. *//** The drawing's width; the body ends at BODY, the stub starts at STUB. */const W = 290;const BODY = 216;const STUB = 219;/** The top and bottom edges at the two ends, and how much the middle bows up. */const TOP = 13;const BOTTOM = 211;const BOW = 12;/** Outer and inner corner radii. *//* Ghost pass (ghost-pass.tsx): a lavender wash fading to white down the stage, the pass floating at the top, the words, the code pill and the two buttons under it. At night the wash is a deep violet over near-black; the pass itself keeps its pastel colours. Every size is a multiple of --u: 1px on a stage 880px wide or more, scaling down with the stage's width (a container query) to 0.66px, under which the headline wraps instead. Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page, or the one its colorScheme prop sets. Motion. The entrance is CSS animations keyed by data-phase (wait, intro, ready). The float, the rays, the coins and the blink are endless CSS animations, paused offscreen (data-paused) and off under reduced motion. The lean and the spin are a transform the script writes. */.root { --surface: #fff; --wash: #e1dffd; --wash-2: #f3f1ff; --wash-3: #fdfcff; --wash-side: #d9d4fe; --ink: #252128; --muted: #64616b; --faint: #9c9aa2; --accent: #9078fa;// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends * the step in progress at once: its promise rejects, which ends the script right there, and the * pointer fades out. * * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame. * The points of the flight are handed to whoever wants to move along with it (the pass, which * tilts towards it). The arrow and the hand are HTML boxes * that swap by their visibility: a change of style inside an SVG would lay the SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = {// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. `visible` and `reduced` are passed on too, so * the component can pause its own loops offscreen and keep still under reduced motion. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(false); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden);- A section labelled with the pass’s name, with a real h2 headline, a paragraph, and buttons or links for every action.
- The pass is a button: arrow keys, Enter or Space spin it, and its label says so; the drawing inside is aria-hidden.
- The code is plain, selectable text (one click selects all of it), and Copy code announces Code copied through a polite live region.
- Every control shows a focus ring on keyboard focus only.
- Under reduced motion the entrance is skipped, nothing floats, turns or blinks, and the pass neither leans nor spins.
- The demo pointer is decorative (aria-hidden) and stops at the first real input.