Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

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.tsx
Live · interactive
Card number
hidden, ending in 3 0 8 7

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

  • 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:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/card-reveal.json

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

card-details.tsx
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.
PropTypeDefaultDescription
numberstring'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.
labelstring'Card number'The caption, and the name used in the button’s label and the announcements.
hideAfternumber6000How 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.
autoplaybooleantrueWhile 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.
classNamestring—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.

card-reveal.tsx
// 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.module.css
/* 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. */
spring.ts
/* * 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;
demo-cursor.tsx
'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>  );}
demo-cursor.module.css
/* 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-autoplay.ts
'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.