Card reveal
A masked card number whose hidden digits roll into place one after another; copy it, and it hides itself again.
card-reveal/card-reveal.tsxThe 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
- A stored card number, account number or IBAN that is masked by default and shown only on request.
- Settings, wallets and billing pages where the reader sometimes needs the whole number to copy it somewhere else.
- Values that should hide again on their own, so a number left open on a shared screen doesn’t stay open.
- One number at a time with a single button that shows it first and copies it once shown.
When not to use
- Entering a card number. Use a real input with formatting and validation.
- Passwords, API keys or one-time codes: the digits-and-groups layout is made for card-like numbers. Use a secret field.
- Numbers that are safe to show, like an order number. Show them plainly with a copy button.
- A list of many masked numbers: one reveal each, each with its own countdown, is noisy. Show the last four and open a detail view.
You can add this component with the shadcn CLI or by hand:
Into components/card-reveal of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/card-reveal.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.2 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 { CardReveal } from './card-reveal';export function CardDetails() { return ( <CardReveal label="Card number" number="4921735018463087" hideAfter={8000} 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.
A card number with its middle digits hidden, and an eye button. Reveal it and each hidden digit rolls up into place a beat after the one before, landing faint and inking in, while the eye turns into a copy button. Copying turns it green with a check for a moment, and a ring round the button runs down the time until the number hides again.
- Each hidden digit sits in a slot one line tall, with soft masked edges, on a reel of two glyphs: the dot and the digit.
- Revealing slides each reel up by a line on a bouncy spring, 42 ms after the one before. The glyphs cross-fade with a touch of blur, and each digit lands faint and inks in a beat later. Hiding plays it back down.
- It is all CSS transitions keyed off one data attribute on the root (hidden, shown, copied), so nothing runs at rest, and an interruption reverses from wherever each digit is.
- The button cross-fades its icons with a blur: eye, copy, check. A ring round it, an SVG stroke whose dash runs out, shows the time left and restarts on every press.
- Copy writes the digits to the clipboard, and only turns green once that has worked. The demo shows the copied look without touching the clipboard.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop; the demo cursor reads the stage’s pointer tokens, and its press ripple turns light at night.
| Prop | Type | Default | Description |
|---|---|---|---|
number | string | '4921735018463087' | The digits (spaces are ignored). The first four and the last four stay visible. The default is made up and fails the card checksum, so it cannot be a real card. |
label | string | 'Card number' | The caption, and the name used in the button’s label and the announcements. |
hideAfter | number | 6000 | How long the number stays shown after the last press, in ms. |
defaultState | 'hidden' | 'shown' | 'copied' | — | Opens in this state and turns the demo off; shown or copied starts the countdown, and copied keeps its check until a press or the hide. |
onStateChange | (state: 'hidden' | 'shown' | 'copied') => void | — | Called on each change: shown, copied, back to shown after the check, hidden. |
autoplay | boolean | true | While on screen and untouched, a demo cursor reveals the number and “copies” 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, useEffectEvent, useId, useRef, useState, type CSSProperties } from 'react';import { DemoCursor, playDemo } from './demo-cursor';import { spring } from './spring';import { useAutoplay } from './use-autoplay';import styles from './card-reveal.module.css';/* * Card reveal: a card number with its middle digits hidden ("4921 •••• •••• 3087") and an eye * button. Revealing rolls each hidden digit into place, one after another, and the eye turns into * a copy button; copying turns it green with a check for a moment. A ring round the button runs * down the time left, and then the number hides itself again. * * - Each hidden digit sits in a slot one line tall, on a reel of two glyphs, the dot and the * digit. Revealing slides the reel up by a line on a bouncy spring, a little later for each * slot; the glyphs cross-fade with a touch of blur, and each digit lands faint and inks in. * Hiding plays it back down. * - All of it is CSS transitions keyed off one data attribute, so nothing runs at rest. * - The button keeps its place and its label says what it does: show, copy, copied. Escape hides * the number. Screen readers get the number digit by digit, and the changes announced. * - While on screen and untouched, a demo cursor reveals the number and copies it (the demo/* Card reveal (card-reveal.tsx): a stage with the card number in a field raised on it. The root's data-state (hidden, shown or copied) drives every transition. Colours are the tokens below, light-dark(light, dark) where the two differ: 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). */.root { /* The stage, its dot grid and its edge; the field raised on it, its hairline and its lift. */ --surface: #e4e4e7; --grid: rgb(0 0 0 / 0.055); --line: rgb(0 0 0 / 0.06); --surface-2: #fff; --edge: rgb(0 0 0 / 0.05); --shadow-near: rgb(0 0 0 / 0.04); --shadow: rgb(0 0 0 / 0.22); /* The words: the caption, the number shown, and the number while partly hidden. */ --caption: #636366; --ink: #1d1d1f; --ink-muted: #6e6e73; /* The hidden digits' dots, and the faint colour a digit lands in before it takes the ink. */ --faint: #aeaeb2; --pale: #c7c7cc; /* The button: the eye's (tinted, its glyph in the accent), the copy's, then the check's. *//* * Springs as CSS easings. A spring pulls a value to its target and lets it settle: fast at first, * then slowing down, overshooting a little and coming back if it is bouncy. CSS `linear()` draws * any curve through a list of points, so the spring's motion is sampled once and handed to CSS * transitions as an easing. */export type Spring = { /** The curve, as a CSS `linear()` easing. */ easing: string; /** How long it takes to settle, in ms: the duration to play the curve over. */ duration: number;};/** Points sampled along the curve: plenty for a smooth motion. */const SAMPLES = 40;/** * A spring that covers most of the distance in about `response` ms. With a `bounce` of 0 it * settles without overshooting; at 0.3 it overshoots by about 5% and comes back. */export function spring(bounce: number, response: number): Spring { // The damping ratio (1: no overshoot) and the natural frequency, in radians per second. const damping = 1 - bounce;'use client';import type { Ref } from 'react';import styles from './demo-cursor.module.css';/* * The demo's cursor. While a demo plays on its own, an arrow glides to the controls and presses * them, so the demo reads like someone using the component. Each glide is a CSS transition, one * per axis with a different easing, which bends the path into a gentle arc: nothing runs between * moves. */export function DemoCursor({ ref }: { ref: Ref<HTMLSpanElement> }) { return ( <span ref={ref} className={styles.cursor} data-state="hidden" aria-hidden="true"> <span className={styles.vertical}> <span className={styles.ripple} /> <svg className={styles.arrow} viewBox="0 0 20 20"> <path d="M4 2.5 15 13h-4.6l2.4 5.2-2.4 1-2.4-5.2L4 17.2z" /> </svg> </span> </span> );}/* The demo's cursor (demo-cursor.tsx): an arrow whose tip sits at (--x, --y) in its stage. The outer box carries the horizontal move, the inner one the vertical move, eased differently. Its colours are the stage's tokens (card-reveal.module.css). */.cursor { --x: 0px; --y: 0px; --move: 0ms; position: absolute; top: 0; left: 0; z-index: 10; pointer-events: none; opacity: 0; transform: translateX(var(--x)); transition: opacity 0.25s ease, transform var(--move) cubic-bezier(0.45, 0, 0.25, 1);}.cursor[data-state='shown'],.cursor[data-state='pressed'] { opacity: 1;}.vertical {'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;- One button that says what it does: “Show card number”, then “Copy card number”, then “Card number copied”. It keeps its place, so focus never jumps, and Escape hides the number.
- Screen readers get the number digit by digit, or “hidden, ending in 3 0 8 7”. The decorative reels are hidden from them.
- Reveal, copy, hide and a failed copy are announced politely, the reveal with the number of seconds it stays shown.
- Under reduced motion the digits switch at once, the ring is not drawn and the demo does not play; the number still hides itself.