A complete "enter the code we sent you" card built like an airport departures board. Four dark tiles, each split across the middle. A glowing ring glides to the tile that takes the next digit, and each digit arrives the way a split-flap does: the top leaf falls through the hinge, casting a shadow on the half below, and lands with a small clack. While the code is checked, a light passes over the tiles. A wrong code scrambles the board: every tile clacks through two random digits in red and lands blank. A right one flips the tiles one after another to green faces that spell DONE, and a Continue button rises.
Good for
Two-step sign-in, phone and email confirmation, ticketing and travel products, anywhere a little mechanical theatre fits the brand.
How it works
One real input sits invisibly over the tiles, so paste, SMS autofill and screen readers work the usual way. Your check runs as soon as the last digit is in, and its answer picks the ending. The word the board flips to is an option, so OKAY, YES or a row of ticks are a one-line change. A resend button counts down before it can be pressed.
How to use
Copy two files, paste the markup, pass your own onComplete(code). Four or six tiles: add or remove slots.
Specs
Price
$5, one payment
Version
1.0.0
Code size
7.8 KB gzipped
Dependencies
None
Built with
HTML, CSS, JavaScript
Category
Form
Licence
Unlimited personal and client projects
In the zip
flap.cssflap.jsthe component
index.htmla plain example page
README.mdpackage.jsonvite.config.jsLICENSE.md
Use it in your project
Copy flap.css and flap.js into your project.
Add the markup from index.html (the block between the COMPONENT comments). One empty .flap-tile per digit: four are in the example, six work the same. The halves and the leaf inside each tile are built for you.
Start it and connect your own check:
import { init } from './flap.js';
const flap = init(document.querySelector('.flap'), {
onComplete: async (code) => {
const res = await fetch('/api/verify', { method: 'POST', body: JSON.stringify({ code }) });
if (!res.ok) throw new Error('That code has expired. Send a new one.'); // shown under the board
},
onResend: () => fetch('/api/send-code', { method: 'POST' }),
onContinue: () => { location.href = '/dashboard'; },
successText: 'DONE', // or 'OKAY', '✓✓✓✓', 'YES' … centred across the tiles
});
onComplete runs as soon as the last digit is in. Return false or throw to show an error: the board scrambles, each tile clacking through two random digits in red before landing blank, so the user can type again. Resolve and the tiles flip one after another to the letters of successText on green faces, and a Continue button appears.
There is one real <input> under the tiles, so the things that normally work with a code field still do: paste, SMS and email autofill (autocomplete="one-time-code"), the numeric keyboard on phones, and screen readers.
The full source, an example page and a README. No account, no framework.
Can I use it in client projects?
Yes, in unlimited projects. You can't resell or share the source itself.
Does it check the code?
It calls your function with the code and shows the answer. Checking belongs on your server; the demo only compares against a fixed code.
Can I change the word at the end?
Yes. successText takes any text; it's upper-cased and centred across the tiles.
Does autofill from SMS work?
Yes. It's a normal input with autocomplete="one-time-code", so iOS and Android offer the code from the message, and the tiles clack in one after another.
Is it accessible?
One labelled field, numeric keyboard on phones, live status messages, aria-invalid on errors, and focus moves to Continue once verified. With reduced motion nothing flips.
Light and dark?
Both are designed. The board itself stays dark in the light theme, like a real one, and every colour is a CSS variable.
What if it doesn't work for me?
If it doesn't work like the demo, you get a refund within 14 days.
Still have a question? Email us. The person who made it answers.
<!-- one real input; js
draws each piece's
outline as an svg --><div class="dovetail-row"><input class="dovetail-input"
…><span class="dovetail-piece" aria-hidden="true"></span>
dovetail.css
/* js sets each slide's
length; a cut-off one
resumes where it is */
.dovetail-piece {
translate: 00;
transition:
translate
var(--dovetail-move)
var(--dovetail-glide), …
.dovetail-piece.is-lifted {
… scale: 1.06; }
A one-time code field of puzzle pieces that slide together and lock into a check
11 KB0 depsForms
index.html
<!-- a coal per slot; the
check has no d, js
fits it to the row;
sparks spawn in fx --><span class="ember-slot" …><span class="ember-wick"></span></span>
…
<svg class="ember-mark" …><path class="ember-check"/><span class="ember-fx" …>
ember.css
/* two copies of the digit:
a blurred one behind it
carries the heat */
.ember-num, .ember-heat {
grid-area: 1 / 1;
…
.ember-heat {
color: var(--ember-flame);
filter: blur(7px);
opacity: .3; …
ember.js
// one eased k draws the// stroke and puts the spark// at its tip, via along(k)const k = easeInOut(
Math.min(1, (now - t0)
/ dur));
const [x, y] = along(k);
… p.style.strokeDashoffset
= String(1 - k);
… c.setAttribute('cx', x);
A one-time code field where each digit lights like an ember and a wrong code turns to ash
9.3 KB0 depsForms
index.html
<!-- the shackle sits
outside the body, so
it lifts and swings --><svg class="hasp-shackle" …><div class="hasp-body"><!-- one real input over
all four wheels --><input class="hasp-input"
…><span class="hasp-wheel" aria-hidden="true">
hasp.css
/* the wheel clips a strip:
--hasp-at picks the row,
a spring rolls it in */
.hasp-strip {
transform: translateY(
calc(var(--hasp-at) *
var(--hasp-wheel-h)
* -1));
transition: transform
var(--hasp-roll, 420ms)
var(--hasp-glide);
hasp.js
// the further it has to go,// the longer: 40ms a rowconst dist = Math.abs(
at - from);
strip.style.setProperty(
'--hasp-roll',
`${Math.min(640,
240 + dist * 40)}ms`);
// then just name the row
strip.style.setProperty(
'--hasp-at', at);
A combination-padlock code field whose wheels roll and whose shackle springs open
7.7 KB0 depsForms
index.html
<!-- clip ends at the hole
line, so the bar sinks
into the case --><g clip-path=
"url(#latch-clip-above)"><g class="latch-shackle"><g class="latch-bar"><!-- one d, four widths: a
round polished tube --><path class="latch-tube
latch-tube-1" d="…"/>
latch.css
/* pivot on the long leg,
in SVG viewBox units */
.latch-shackle, .latch-bar {
transform-box: view-box;
transform-origin:
69px30px; }
/* scaleX fakes the turn;
stroke width holds */
.latch-tube { …
vector-effect:
non-scaling-stroke; }
latch.js
// v is kept on retarget, so// a spin never jerks
v += (-k * (x - target)
- c * v) * (dt / 4);
x += v * (dt / 4);
…
// speed blurs the digitsconst b = Math.min(2.6,
(Math.abs(v)*cell) / 900);
if (b > 0.25) { strip.style
.filter = `blur(${b…}px)`;