cardstock

Number and reveal

A masked number that decodes digit by digit, in cells that never shift.

Card.Number shows the card number masked except for its last four digits. Card.SecurityCode masks all of it. When the card is revealed, the digits resolve left to right, each one cycling through a few random digits first.

<Card.Number value="4821 5903 2716 4822" />
<Card.SecurityCode value="731" />

Timing

The reveal takes 300ms and eases out (cubic-bezier(0.23, 1, 0.32, 1)), so the first digits land almost at once. Hiding takes 150ms. These follow Emil Kowalski's rules for UI motion: stay under 300ms, ease out on the way in, and leave faster than you arrive. With reduced motion there is no scramble. Change the timing with revealTiming on Card.Root.

Cells

Each character is its own <span> with:

Attribute / variableValue
data-char-statestatic (spaces, visible digits), masked, scrambling, revealed
--char-indexPosition in the text
--group-indexWhich four-digit group it's in

The mask character • isn't a digit. The font it falls back to can be wider or taller than your digits, so give the cells a fixed width, or the line shifts while dots turn into digits:

[data-char-state] {
  display: inline-block;
  width: 1ch;
  text-align: center;
}

Your own reveal

Set reveal="none" and the cells only switch between masked and revealed. You animate them yourself, for example staggered by --char-index:

[data-char-state="revealed"] {
  animation: in 180ms ease-out both;
  animation-delay: calc(var(--char-index) * 12ms);
}

Or pass a function as children and render the cells however you like:

<Card.Number value={pan}>
  {(cells) =>
    cells.map((c) => (
      <b key={c.index} data-state={c.state}>
        {c.char}
      </b>
    ))
  }
</Card.Number>

maskText() and decodeAt() are exported too, as pure functions of the text and a 0–1 progress.

Reveal groups and timeouts

revealed on Card.Root shows every detail at once. To show the number and the security code separately, give each its own Card.RevealGroup. A group renders no element. A part follows a group when the group wraps it, or when the part names the group with group. Naming works across faces, and from triggers outside the card:

<Card.Root>
  <Card.RevealGroup id="number" timeoutMs={30_000} />
  <Card.RevealGroup id="code" timeoutMs={15_000} />
  <Card.Body>
    <Card.Front>
      <Card.Number group="number" value={pan} />
    </Card.Front>
    <Card.Back>
      <Card.SecurityCode group="code" value={securityCode} />
    </Card.Back>
  </Card.Body>
  <Card.RevealTrigger group="number">Show number</Card.RevealTrigger>
  <Card.RevealTrigger group="code">Show security code</Card.RevealTrigger>
</Card.Root>
  • A group takes revealed, defaultRevealed and onRevealedChange, just like the root.
  • timeoutMs hides the group again that long after each reveal. The digits scramble back and onRevealedChange(false) fires. A controlled group stays shown until you update it. For the card-wide reveal, use revealTimeoutMs on Card.Root.
  • The card-wide revealed shows every group. Hiding any group while it's on turns the card-wide reveal off too.
  • Freezing hides every group, disables its triggers, and tells each owner.
  • Grouped parts carry data-reveal-group. useRevealScope(group?) returns the same scope for your own parts.

Masking only changes what is displayed. The full value is still in your props, so your app is responsible for authorizing access to it and for how it is fetched and held.

Copying

Card.CopyTrigger copies a detail. It is only enabled while that detail shows: it's disabled while hidden or frozen, and it never copies on its own when the detail is revealed. Like the other parts, it follows the group around it or the one named by group.

<Card.CopyTrigger
  group="number"
  value={pan.replace(/\s/g, "")}
  onCopyResult={(r) => toast(r.ok ? "Copied" : "Couldn't copy")}
>
  Copy number
</Card.CopyTrigger>
  • It writes with navigator.clipboard.writeText by default. Pass copy to use your own adapter, such as a native bridge or a test double. Throw or reject to report a failure.
  • data-status is idle, copied or failed, and goes back to idle after resetMs (2000). It also resets as soon as the detail hides.
  • onCopyResult receives { ok: true } or { ok: false, error }, so the feedback stays yours.

Holder and expiry

Card.Holder and Card.Expiry render a <span> with data-revealed. Pass maskWhenHidden to mask the digits of their text until the card is revealed.

On this page