The lab · 11 experiments
Small things, done properly
Each one started as a real problem on a payments product. They’re rebuilt from scratch here, with fake data, so you can poke at them.
Card number field
Formats as you type without moving the caret, detects the network from the first digits and runs the Luhn check. The highlighted digits are the ones Luhn doubles.
Read the post →One-time code boxes
Six boxes that behave like one field: paste anywhere and the digits spread out, backspace walks back, arrows move. The autofill hint goes on the first box only.
Type or paste a code. Try pasting 482913.
Money input
Groups digits in the currency’s own locale, trims to 11 whole digits and the currency’s decimals, and sends a clean number while showing a friendly one.
Read the post →Paste “1234567.891” or type past the limits.
Deadline badge
One way of talking about time everywhere: days, then hours in amber, then overdue in red. Drag the slider to move the clock.
Read the post →- A day or more → days, rounded down
- Less than a day → hours, in amber
- Past the deadline → overdue, in red, with how long ago
Maker-checker approval
The person who requests a debit can’t approve it. The rule lives in the UI and the API; this shows the UI half.
Request · REQ-0031
Debit $1,240.00 from merchant balance
Filter chips
Each chip owns one filter. Apply and Clear both close the chip, Escape closes everything, and focus returns to the chip.
Read the post →| Payment | From | Status | Amount |
|---|
Search with rotating examples
The placeholder cycles through real kinds of queries, pauses while you type, and the search waits 300 ms after your last keystroke.
Searches fire 300 ms after you stop typing.
Copyable cells
Bank details truncate in narrow tables, but the copy button always copies the full value. If the clipboard is blocked, it selects the text instead.
| Account number | GB33BUKB20201555555555 |
| Routing (Fedwire) | 026009593 |
| Payment reference | PAY-1042-2026-10-29-LIS |
Sample values only. Narrow the window: the text truncates, the copy doesn’t.
Read before you accept
The accept checkbox unlocks when the last line of the agreement becomes visible, using IntersectionObserver rather than scroll arithmetic.
Sample merchant agreement · v3
1. This is placeholder text standing in for clause 1. A real agreement would explain fees, settlement timelines, refunds and disputes here, in language a merchant can actually follow.
2. This is placeholder text standing in for clause 2. A real agreement would explain fees, settlement timelines, refunds and disputes here, in language a merchant can actually follow.
3. This is placeholder text standing in for clause 3. A real agreement would explain fees, settlement timelines, refunds and disputes here, in language a merchant can actually follow.
4. This is placeholder text standing in for clause 4. A real agreement would explain fees, settlement timelines, refunds and disputes here, in language a merchant can actually follow.
5. This is placeholder text standing in for clause 5. A real agreement would explain fees, settlement timelines, refunds and disputes here, in language a merchant can actually follow.
6. This is placeholder text standing in for clause 6. A real agreement would explain fees, settlement timelines, refunds and disputes here, in language a merchant can actually follow.
7. This is placeholder text standing in for clause 7. A real agreement would explain fees, settlement timelines, refunds and disputes here, in language a merchant can actually follow.
— End of agreement —
Scroll to the end to enable the checkbox.
Header text that picks its own colour
Merchants pick any brand colour; the header text chooses black or white from the colour’s relative luminance so it always reads.
Read the post →A loader without a library
The kind of CSS loader that replaced an 80 KB animation library on a checkout. Two keyframes, no JavaScript.
Read the post →0 KB of JavaScript · two keyframes · respects reduced motion
@keyframes spin { to { transform: rotate(1turn); } }
.ring {
border: 3px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .8s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.ring { animation: none; }
}