A complete "enter your security code" card built like a piece of hardware. Every digit is a number wheel in a machined slot. When you type, the wheel spins and settles on the digit, a lamp under it lights, and the numbered dial on the padlock above is turned right, then left, bringing each digit's number under the index, the way a combination is dialled. The padlock is drawn like the real thing: a polished shackle that sits in its holes, a brushed steel body and a black dial with a knurled brass rim. While the code is checked, the tumblers are tried one after another. A wrong code turns the lamps red, the shackle is yanked twice and stops hard against the latch, the dial spins back to zero and the wheels to blank. A right one turns the lamps green one by one, then the shackle springs up out of its hole and turns open on its long leg before a Continue button appears.
Good for
Sign-in codes, two-step verification, unlocking a vault, wallet or private area, phone and email confirmation.
How it works
One real input sits invisibly over the wheels, so paste, SMS autofill and screen readers work the usual way. Your check runs as soon as the code is complete, and its answer picks the ending. 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 eight digits: add or remove wheels.
Specs
Price
$12, one payment
Version
1.0.0
Code size
11 KB gzipped
Dependencies
None
Built with
HTML, CSS, JavaScript
Category
Form
Licence
Unlimited personal and client projects
In the zip
latch.csslatch.jsthe component
index.htmla plain example page
README.mdpackage.jsonvite.config.jsLICENSE.md
Use it in your project
Copy latch.css and latch.js into your project.
Add the markup from index.html (the block between the COMPONENT comments). One empty .latch-wheel per digit: six are in the example, four or eight work the same. The script fills each wheel in.
Start it and connect your own check:
import { init } from './latch.js';
const latch = init(document.querySelector('.latch'), {
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 field
},
onResend: () => fetch('/api/send-code', { method: 'POST' }),
onContinue: () => { location.href = '/dashboard'; },
});
onComplete runs as soon as the last digit is in. Return false or throw to show an error: the lamps go red, the padlock is tugged and holds, and the wheels spin back to blank so the user can type again. Resolve and the lamps turn green one by one, the padlock's shackle springs up and turns open on its long leg, and a Continue button appears.
There is one real <input> over the wheels, so the things that normally work with a code field still do: paste, SMS 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.
Does autofill work?
Yes. It's a normal input with autocomplete="one-time-code", so phones offer the code from the message, and the wheels spin 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 unlocked. With reduced motion nothing spins.
Can I leave out the padlock?
Yes. Delete its SVG and the wheels, lamps and messages work the same.
Light and dark?
Both are designed: brushed aluminium and graphite. 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.
<!-- eyes before the head:
they sink behind it --><g class="tuck-eye
tuck-eye--back">
…
<path class="tuck-body" …/><!-- the engine finds the
password by this --><input class="tuck-input
tuck-input--secret" …
data-mascot="secret" …>
tuck.css
/* hide: stalks shrink to
their base, the eyes sink
behind the head */
.tuck[data-mood="hide"]
.tuck-stalk, …
{ transform: scaleY(0); …
.tuck[data-mood="hide"]
.tuck-eye--front, …
{ transform:
translate(4px, 54px); …
mascot.js
// the eyes chase the caret:// width of the text before// it, in the input's font
ctx.font = `${cs.fontWeight}
${cs.fontSize} …`;
…
// scrolled fields shift the// text; clamp to the field
x: clamp(left + ctx
.measureText(text).width
- input.scrollLeft, …
Sign-up, sign-in and reset forms with a snail that pulls its eyes in while you type your password
10.6 KB0 depsForms
index.html
<!-- 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
<!-- one real input on top;
the tiles stay empty,
flap.js adds halves
and a hinged leaf --><div class="flap-row"><input class="flap-input" id="otp" type="text" …><span class="flap-tile" aria-hidden="true"></span>
…
flap.css
/* one leaf, two faces:
the back waits folded
behind the front */
.flap-tile {
perspective: 480px; …
.flap-leaf-front,
.flap-leaf-back {
backface-visibility:
hidden; …
transform:
rotateX(180deg); }
flap.js
// new glyph: top half and// leaf back; the old one// stays on the leaf frontpaint(c.top, glyph, tone);
paint(c.back, glyph, tone);
// fall to -180, bounce 5°// back and settle: clackconst a = c.leaf.animate(
… 'rotateX(-180deg)',
offset: 0.86, …
'rotateX(-175deg)', …