Sometimes you need a six-digit code. A test OTP. A quick PIN for a demo. A “pick a random number between zero and a million” moment that takes too many keystrokes to do in a console.
So I built one. But making yet another Math.floor(Math.random() * 1000000) form felt sad. So I made it a card trick instead.
It is here: /tools/otp. Tap Shuffle. Tap a card. Tap Copy.
How it looks
Each digit of the code becomes one playing card. Six cards by default, anywhere from four to twelve via a small − and + counter at the top. Each card shows:
- A rank in the top-left corner (the digit)
- A suit (♠ ♥ ♦ ♣) below the rank, and large in the middle
- The same rank and suit mirrored in the bottom-right corner, rotated 180 degrees, like a real card
Hearts and diamonds are red. Spades and clubs are black. The suit on each card is decorative and re-rolled with every shuffle. The OTP value is the digits, not the suits.
The card backs are deliberately classic: solid accent red, a 45-degree cross-hatch pattern, a thin white inner frame, and an “AG” emblem pill in the middle. The kind of thing you would expect to find in a pack from the corner shop.
The shuffle
Clicking Shuffle plays a four-phase animation:
- All cards flip face-down with a 30 ms stagger.
- Each card translates to the row center, with a small random rotation. The deck visually re-forms.
- A 110 ms-period jitter on the stack. The “shuffling” feel.
- Cards release back to their row positions, then flip face-up with a 90 ms stagger.
The “translate to center” step is computed live, not hard-coded:
const rowRect = rowEl.getBoundingClientRect();
const centerX = rowRect.left + rowRect.width / 2;
cards.forEach((c) => {
const rect = c.getBoundingClientRect();
const dx = centerX - (rect.left + rect.width / 2);
c.style.setProperty("--gather-x", dx + "px");
c.style.setProperty(
"--shuffle-rotate",
(Math.random() * 24 - 12) + "deg"
);
});
So the animation works for any card count without me telling it where the row is. Add more cards via +, the math still lines up.
Tap a single card and only that one re-rolls. It flips back, picks a new digit and a new suit, then flips forward. The other cards keep their values.
The randomness, taken seriously
This part is small but it matters. An OTP has security expectations, and Math.random is not a real random source. So I used crypto.getRandomValues with rejection sampling:
function randomDigit() {
const buf = new Uint8Array(1);
do {
crypto.getRandomValues(buf);
} while (buf[0] >= 250);
return buf[0] % 10;
}
The rejection sampling matters more than people realise. A naive byte % 10 is biased: 256 divided by 10 is 25.6, so digits 0 through 5 each appear 26 times in the range 0..255 while digits 6 through 9 appear 25 times. Tiny bias, but bias. Throwing out values from 250 to 255 leaves a clean 0..249 range that divides by 10 exactly.
You would never notice it on a single code. Roll a million and you would.
Real cards, from a CDN
The first version drew the cards itself: a digit, a suit symbol, two mirrored corners. Functional. Not beautiful.
I have since wired it up to use the actual vector-playing-cards art by Chris Aguilar (LGPL-3.0, originally on Google Code). All four published versions of the deck are mirrored in their own repo so the files are easy to fetch from a CDN: AbhiiGatty/vector-playing-cards-archive.
The decisive call: do not bundle the SVGs into this repo. Serve them from the archive repo via jsDelivr, which serves any public GitHub repo as an edge-cached CDN. The cards repo stays at ~55 MB on disk; this site stays light; users get the same files, edge-cached, on first request.
I also renamed every file in the archive to a strictly numeric scheme so URL construction is trivial:
card_<suit>_<rank>.svg
Suits 1 through 4 are hearts, clubs, spades, diamonds. Ranks 1 through 13 are ace through king. With that, picking a random card is just two integers and a string interpolation:
const suit = 1 + Math.floor(Math.random() * 4);
const rank = 1 + Math.floor(Math.random() * 13);
const url =
`https://cdn.jsdelivr.net/gh/AbhiiGatty/vector-playing-cards-archive` +
`@v1.3.0/svg/1.3/card_${suit}_${rank}.svg`;
The OTP digit-to-rank mapping is a one-liner: 0 becomes a 10 (the only rank with a 0 in it), 1 through 9 stay as themselves.
The loader that is not a loader
Switching to a network-fetched image creates a small problem: the card front becomes a placeholder until the SVG arrives. A spinner here would be ugly.
The fix is choreography. The shuffle animation already has a 520 ms “deck jitter” pause between gathering the cards and dealing them back out. I run the image preloads in parallel with that jitter:
rowEl.classList.add('is-jittering');
const newDigits = [];
const newSuits = [];
const preloads = [];
for (let i = 0; i < cards.length; i++) {
const d = randomDigit();
const s = 1 + Math.floor(Math.random() * 4);
newDigits.push(d);
newSuits.push(s);
preloads.push(preloadImage(cardUrl(s, digitToRank(d))));
}
await Promise.all([wait(JITTER_MS), Promise.all(preloads)]);
By the time the deck releases and the cards flip face-up, every image is already in the browser cache. The reveal is instant. No spinner. The animation is the loader.
After first load the SVGs are cached for a week per jsDelivr’s cache-control headers, so the second visit feels even faster.
Try it
/tools/otp. Tap Shuffle. Tap a card. Tap Copy. Repeat until you are satisfied with your luck.
If it ever rolls a code that is meaningful to you, take it as a sign.
Try the live tool: /tools/otp