Scratch card
NewA gift card that you scratch with a pointer or a finger to reveal a glowing $299 prize.
scratch-card/scratch-card.tsxPlan / PRO
Period / 8760H
Generated
2/26/2026 10 PM
You can add this component with the shadcn CLI or by hand:
Into components/scratch-card of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/scratch-card.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 { ScratchCard } from './scratch-card';export function Gift() { return ( <ScratchCard autoplay={false} name="Delorence" plan="PRO" period="8760H" generated="2/26/2026 10 PM" onReveal={() => console.log('revealed')} /> );}A gift card for Delorence with a dotted face. Hover it and it grows under a glow of green, teal and blue that follows the pointer and tints the frame. Drag across it to reveal a $299 prize through a pixel-stepped scratch. Your progress stays when you leave the card; Reveal and Cover again work from the keyboard too.
- The pixel mask follows a 64 by 32 grid. The brush joins every pair of pointer positions, including coalesced samples, so quick drags reveal a continuous path while keeping the stepped edges.
- The prize, lit from below, is painted once on a canvas kept aside, with transparency between the black isometric shapes. The dotted face and its glow stay visible through these gaps; there is no opaque image covering them. The visible canvas is capped at twice the screen’s density.
- After 55% of the face has been scratched, the remaining cells dissolve in a random order. A reader’s progress stays after releasing the pointer, moving away or scrolling. Cover again resets it immediately; the idle demo gives up control after a real press.
- Once the card is fully revealed, an SVG of the same prize takes the canvas’s place and the canvas gives its pixels back. It gives them back too when explicitly covered, or when an untouched demo scrolls off screen.
- The glow is one fixed box on the face and one on the frame, both drifting at nearly the pointer’s pace by their transform. The card grows and tilts on hover; its current transform is frozen while scratching to keep the brush aligned with the pointer.
- The card, its dotted face, its labels, its glow and the stage are light-dark() tokens on the root, so it follows the page’s color-scheme, or the colorScheme prop: a white card with a silver face under a pastel glow by day, a near-black one at night. The revealed backing follows the card’s colour in both the canvas and SVG.
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | 'Delorence' | Who the card is for, at its top right. |
plan | string | 'PRO' | The plan, at its bottom left. |
period | string | '8760H' | The period, under the plan. |
generated | string | '2/26/2026 10 PM' | When the card was made, at its bottom right. |
onReveal | () => void | — | Called when the reader has revealed the card. |
autoplay | boolean | true | While on screen and untouched, a demo pointer hovers the card and scratches it. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root, a stage of its own. |
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, useId, useRef, useState, type FocusEvent, type PointerEvent } from 'react';import { FACE, Mark, Prize } from './art';import { DemoPointer, runDemo, type Point } from './demo-pointer';import { createScratch, type Scratch } from './scratch';import { useAutoplay } from './use-autoplay';import styles from './scratch-card.module.css';/* * Scratch card: a gift card whose dotted face hides a prize. Scratch it with a pointer or a * finger and the price shows through, with the black isometric cards behind it; once enough is * scratched, the rest dissolves away. * * - The cover is the face itself, printed with dots and lit, under the pointer, by a glow of * green, teal and blue that drifts at nearly the pointer's pace and tints the frame around the * face. The transparent prize lies on a canvas over it, seen through the pixel mask * (scratch.ts). Joined strokes reveal the artwork without covering the printed dots or glow. * - Past 55% revealed, the remaining cells dissolve away. * Fully revealed, an SVG of the same prize takes the canvas's place and the canvas gives its * pixels back; it takes them again only to be scratched or to dissolve. * - Under a mouse the card grows and tilts toward the pointer, by one transform. When the pointer/* Scratch card (scratch-card.tsx): a gift card on a stage, a button under it. By day a white card with a silver face on a white stage; at night a black card with a dark face, and a hairline, on a dark grey stage a step above it. Colours are the tokens below, each light-dark(light, dark): the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). The transparent prize leaves the dotted face and its glow visible between the pixel-shaped artwork in both themes. Every size of the card is a multiple of --u, its width over 200; the stage is a size container and the card's width follows it. The scene is one layout boundary of a size computed from that width, room left for the card grown under a mouse. Memory. The card grows and tilts by one transform, on a layer of its own. The glow under the pointer is one fixed-size box that moves by its transform. The canvas is the face's size, at up to twice the screen's density, and only while it is scratched or dissolving. */.stage { /* The stage and its edge. */ --surface: #fff; --edge: rgb(0 0 0 / 0.06); /* The card: its frame, its hairline, its near shade and its drop; its mark, and its name. */ --card: #fbfbfc; --card-edge: rgb(0 0 0 / 0.08);import { CELL, FACE, GRID, PRICE, PRICE_DIM, PRICE_FONT, PRICE_LIT, PRICE_SHADE, SLAB_COLOR, SLAB_RUNS,} from './art';/* * The scratch, on a canvas. What lies under the cover (art.tsx) is painted once on a canvas kept * aside; the visible canvas shows it through a mask of one pixel per cell, scaled up without * smoothing, so what is revealed steps along the cells. A stroke clears every cell within a * brush's reach of its path; a dissolve clears (or covers again) the rest a few cells at a time, * in a random order. * * The canvas is sized to the face, at up to twice the screen's density, and only while it shows * something being scratched or dissolving: `release` gives its pixels back. *//* * What the scratch card hides, and the grid it is scratched on. The face is measured in units * (FACE), and divided into cells (GRID) as fine as its printed dots: a cell is scratched off or * not, so the edge of what shows steps like the dots. Under the cover lie two isometric cards in * black, drawn cell by cell, and the price. * * The canvas paints all this while the card is being scratched (scratch.ts); once it is all * revealed, the same drawing as SVG (Prize) takes its place, so the canvas can give its pixels back. *//** The face's size, in the card's units (the card is 200 wide). */export const FACE = { width: 184.6, height: 94.5 };/** The cells it is scratched by: two to a dot across, one to a row of dots down. */export const GRID = { cols: 64, rows: 32 };export const CELL = { width: FACE.width / GRID.cols, height: FACE.height / GRID.rows };/** The price, its place (centre across, baseline down) and its size, in face units. */export const PRICE = { text: '$299', x: 92.5, y: 53, size: 35.4 };/** The price's type, the same for the canvas and for the SVG. */export const PRICE_FONT = `600 ${PRICE.size}px Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`;/** The price's colours across: lit by the card's glow, and in the dark. */export const PRICE_LIT = ['#49eef7', '#71daf3', '#a9c9dd', '#8b9daa'];export const PRICE_DIM = ['#4b4f53', '#55595d', '#5c6064', '#55595c'];'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, * glide the pointer onto a control and press it, or press and drag in a stroke, calling the * component's own handlers as a hand would. 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. * * Nothing the pointer does while the demo plays needs a layout, because one here in the same * frame as one in the card would make the browser lay out the whole page (see the note at the * top of the stylesheet). So it moves by animations the compositor plays (a drag too: the dragged * value follows that animation's clock); the press scales a box that keeps a transform at rest, * since a box that gains or loses one is laid out again; and the arrow and the hand are HTML boxes * that swap by their visibility (a change of style inside an SVG lays the SVG out again). */export type Point = { x: number; y: number };export type Demo = { /** Resolves after `ms`. */ wait: (ms: number) => Promise<void>;'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. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); 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); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- A real Reveal button, which becomes Cover again, gives keyboard and switch users the same result as scratching.
- The scratch panel is an image whose label says what to do, and names the prize once it is revealed. Revealing and covering are announced in a polite live region.
- Only a drag that starts on the scratch panel is captured (touch-action: none there); drags elsewhere scroll the page.
- Under reduced motion the reveal and cover happen at once and the demo never plays. Keyboard focus on the stage stops the demo.