Password
A password field with an eye that crosses itself out, a meter that fills as it gets stronger, a verdict that rolls and rules that tick themselves off.
View codeA one-time code field: one real input drawn as cells. Digits rise into place, paste and SMS autofill just work, and the cells turn to ink once complete.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Use the Kodza OTP Input for my 2FA code.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<div class="kodza-otp" data-length="6">
<p class="kodza-otp__label">Enter the code we sent to florent@kodza.dev</p>
<div class="kodza-otp__field">
<input class="kodza-otp__input" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" aria-label="Six-digit code">
<div class="kodza-otp__cells" aria-hidden="true"></div>
</div>
<p class="kodza-otp__foot">
<span class="kodza-otp__done" aria-live="polite"></span>
<span class="kodza-otp__again">Didn't get it? <button class="kodza-otp__resend" type="button">Resend in <span class="kodza-otp__count">30</span>s</button></span>
</p>
</div>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — OTP Input
A one-time code field: one real input (so paste, SMS autofill and screen readers just work)
drawn as separate cells. Each digit rises into its cell from behind a mask, the active cell
shows a blinking caret, and once the code is complete the cells turn to ink one after the
other. The resend link counts down before it wakes up.
Everything is tuned with the --kz-otp-* variables. */
.kodza-otp {
--kz-otp-bg: #ffffff;
--kz-otp-fg: #0a0a0a;
--kz-otp-muted: rgba(10, 10, 10, 0.6);
--kz-otp-line: rgba(10, 10, 10, 0.14);
--kz-otp-soft: rgba(10, 10, 10, 0.06);
--kz-otp-ink: #0a0a0a;
--kz-otp-on-ink: #ffffff;
--kz-otp-cell: 52px;
--kz-otp-height: 60px;
--kz-otp-gap: 8px;
--kz-otp-radius: 12px;
--kz-otp-ease: cubic-bezier(0.22, 1, 0.36, 1);
display: grid;
justify-items: start;
gap: 14px;
color: var(--kz-otp-fg);
}
.kodza-otp__label {
margin: 0;
font-size: 15px;
font-weight: 500;
}
.kodza-otp__field {
position: relative;
}
/* The real input covers the cells, invisible: it takes the typing, the paste and the focus */
.kodza-otp__input {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
padding: 0;
border: 0;
background: transparent;
color: transparent;
caret-color: transparent;
font-size: 16px;
letter-spacing: 3em;
outline: none;
opacity: 0.01;
}
.kodza-otp__cells {
display: flex;
align-items: center;
gap: var(--kz-otp-gap);
}
.kodza-otp__cell {
position: relative;
display: grid;
place-items: center;
width: var(--kz-otp-cell);
height: var(--kz-otp-height);
overflow: hidden;
border-radius: var(--kz-otp-radius);
background: var(--kz-otp-bg);
box-shadow: inset 0 0 0 1px var(--kz-otp-line);
font-size: 24px;
font-weight: 600;
font-variant-numeric: tabular-nums;
transition: box-shadow 0.2s ease;
}
/* The ink that fills the cell from the bottom once the code is complete: it carries a white
copy of the digit, so the digit turns white exactly where the ink reaches it */
.kodza-otp__cell::before {
content: attr(data-digit);
position: absolute;
inset: 0;
z-index: 1;
display: grid;
place-items: center;
background: var(--kz-otp-ink);
color: var(--kz-otp-on-ink);
clip-path: inset(100% 0 0 0);
transition: clip-path 0.45s cubic-bezier(0.65, 0, 0.35, 1);
}
.kodza-otp__cell > span {
grid-area: 1 / 1;
}
/* A new digit rises into its cell */
.kodza-otp__cell > span.is-new {
animation: kodza-otp-in 0.4s var(--kz-otp-ease);
}
@keyframes kodza-otp-in {
from {
transform: translateY(100%);
}
}
/* The cell waiting for the next digit, while the field has the focus */
.kodza-otp__field:focus-within .kodza-otp__cell.is-active {
box-shadow: inset 0 0 0 1.5px var(--kz-otp-fg), 0 0 0 4px var(--kz-otp-soft);
}
.kodza-otp__field:focus-within .kodza-otp__cell.is-active::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 1.5px;
height: 24px;
background: var(--kz-otp-fg);
transform: translate(-50%, -50%);
animation: kodza-otp-caret 1s steps(2, jump-none) infinite;
}
@keyframes kodza-otp-caret {
50% {
opacity: 0;
}
}
.kodza-otp__sep {
width: 10px;
height: 2px;
margin: 0 2px;
border-radius: 2px;
background: var(--kz-otp-line);
}
/* Complete: ink rises in each cell, one after the other */
.kodza-otp.is-done .kodza-otp__cell {
box-shadow: none;
transition: box-shadow 0.2s ease calc(var(--i, 0) * 60ms + 0.3s);
}
.kodza-otp.is-done .kodza-otp__cell::before {
clip-path: inset(0);
transition-delay: calc(var(--i, 0) * 60ms);
}
/* The foot: the resend line, then the done message, in one cell */
.kodza-otp__foot {
display: grid;
margin: 0;
font-size: 13px;
color: var(--kz-otp-muted);
}
.kodza-otp__done,
.kodza-otp__again {
grid-area: 1 / 1;
transition: opacity 0.25s ease, transform 0.4s var(--kz-otp-ease);
}
.kodza-otp__done {
color: var(--kz-otp-fg);
font-weight: 500;
opacity: 0;
transform: translateY(8px);
}
.kodza-otp.is-done .kodza-otp__done {
opacity: 1;
transform: none;
transition-delay: 0.35s;
}
.kodza-otp.is-done .kodza-otp__again {
opacity: 0;
transform: translateY(-8px);
visibility: hidden;
}
.kodza-otp__resend {
padding: 0;
border: 0;
border-radius: 4px;
background: none;
color: var(--kz-otp-fg);
font: inherit;
font-weight: 500;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
.kodza-otp__resend:disabled {
color: var(--kz-otp-muted);
text-decoration: none;
cursor: default;
}
.kodza-otp__resend:focus-visible {
outline: 2px solid var(--kz-otp-fg);
outline-offset: 2px;
}
.kodza-otp__count {
display: inline-grid;
overflow: hidden;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-variant-numeric: tabular-nums;
vertical-align: bottom;
}
.kodza-otp__count > span {
grid-area: 1 / 1;
}
@media (max-width: 420px) {
.kodza-otp {
--kz-otp-cell: 42px;
--kz-otp-height: 52px;
--kz-otp-gap: 6px;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-otp__cell > span.is-new,
.kodza-otp__field:focus-within .kodza-otp__cell.is-active::after {
animation: none;
}
.kodza-otp__cell,
.kodza-otp__cell::before,
.kodza-otp.is-done .kodza-otp__cell,
.kodza-otp__done,
.kodza-otp__again {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-otp {
--kz-otp-bg: #161616;
--kz-otp-fg: #f2f2f0;
--kz-otp-muted: rgba(242, 242, 240, 0.62);
--kz-otp-line: rgba(255, 255, 255, 0.14);
--kz-otp-soft: rgba(255, 255, 255, 0.08);
--kz-otp-ink: #f2f2f0;
--kz-otp-on-ink: #0a0a0a;
}
*/
/* kodza-guard d1c178b448b0 — generated by Kodza, do not edit.
Theme guard: keeps a site's own CSS (WordPress themes, Bootstrap, CSS resets…) off this
component. :not(#_) is always true and weighs as much as an id, so these lines win over
theme rules made of classes and tags. Customise the component through its --kz-* variables. */
/* What the browser gives, where the component sets it only in some states: at the lowest weight, so its own rules below put it back */
:where(.kodza-otp__resend):not(#_) {
outline: revert;
outline-offset: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-otp:not(#_),
.kodza-otp__field:not(#_),
.kodza-otp__cells:not(#_),
.kodza-otp__cell:not(#_),
.kodza-otp__sep:not(#_),
.kodza-otp__done:not(#_),
.kodza-otp__again:not(#_),
.kodza-otp__count:not(#_),
.kodza-otp__count span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-otp__label:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-otp__input:not(#_) {
margin: revert;
border-radius: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-otp__foot:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-otp__resend:not(#_) {
margin: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-otp__label:not(#_):where(:not([hidden])),
.kodza-otp__input:not(#_):where(:not([hidden])),
.kodza-otp__resend:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-otp__label:not(#_):where([hidden]),
.kodza-otp__input:not(#_):where([hidden]),
.kodza-otp__resend:not(#_):where([hidden]) {
display: none;
}
.kodza-otp__cell:not(#_)::before,
.kodza-otp__cell:not(#_)::after {
box-sizing: revert;
}
/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */
.kodza-otp__label:not(#_) {
margin: 0;
font-size: 15px;
font-weight: 500;
}
.kodza-otp__input:not(#_) {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
padding: 0;
border: 0;
background: transparent;
color: transparent;
caret-color: transparent;
font-size: 16px;
letter-spacing: 3em;
outline: none;
opacity: 0.01;
}
.kodza-otp__foot:not(#_) {
display: grid;
margin: 0;
font-size: 13px;
color: var(--kz-otp-muted);
}
.kodza-otp__resend:not(#_) {
padding: 0;
border: 0;
border-radius: 4px;
background: none;
color: var(--kz-otp-fg);
font: inherit;
font-weight: 500;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
.kodza-otp__resend:disabled:not(#_) {
color: var(--kz-otp-muted);
text-decoration: none;
cursor: default;
}
.kodza-otp__resend:focus-visible:not(#_) {
outline: 2px solid var(--kz-otp-fg);
outline-offset: 2px;
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — OTP Input
// Draws the cells (a dash in the middle), keeps the input to digits, shows each digit in its
// cell (the new one rising), marks the cell waiting for the next one, and plays the "done"
// state once the code is complete. Fires "kodza:complete" with { code }. The resend button
// counts down from data-resend seconds (30) before it can be used, and fires "kodza:resend".
(() => {
const SELECTOR = '.kodza-otp';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const input = root.querySelector('.kodza-otp__input');
const box = root.querySelector('.kodza-otp__cells');
const done = root.querySelector('.kodza-otp__done');
const resend = root.querySelector('.kodza-otp__resend');
const count = root.querySelector('.kodza-otp__count');
if (!input || !box) return;
const length = Number(root.dataset.length) || Number(input.maxLength) || 6;
input.maxLength = length;
const cells = Array.from({ length }, (_, i) => {
const cell = document.createElement('span');
cell.className = 'kodza-otp__cell';
cell.style.setProperty('--i', i);
return cell;
});
const half = Math.floor(length / 2);
box.append(...cells.slice(0, half), Object.assign(document.createElement('span'), { className: 'kodza-otp__sep' }), ...cells.slice(half));
let shown = '';
const render = () => {
const value = input.value.replace(/\D/g, '').slice(0, length);
if (value !== input.value) input.value = value;
cells.forEach((cell, i) => {
const digit = value[i] || '';
if (digit !== (shown[i] || '')) {
const span = document.createElement('span');
span.textContent = digit;
if (digit && !reduceMotion) span.classList.add('is-new');
cell.replaceChildren(span);
cell.dataset.digit = digit;
}
cell.classList.toggle('is-active', i === Math.min(value.length, length - 1) && value.length < length);
});
const complete = value.length === length;
if (complete && !root.classList.contains('is-done')) {
root.classList.add('is-done');
if (done) done.textContent = root.dataset.done || 'Code accepted. Welcome back.';
root.dispatchEvent(new CustomEvent('kodza:complete', { detail: { code: value }, bubbles: true }));
} else if (!complete) {
root.classList.remove('is-done');
if (done) done.textContent = '';
}
shown = value;
};
input.addEventListener('input', render, { signal });
// The caret always sits at the end: the cells read left to right
const end = () => input.setSelectionRange(input.value.length, input.value.length);
input.addEventListener('focus', () => requestAnimationFrame(end), { signal });
input.addEventListener('click', end, { signal });
input.addEventListener('keydown', (event) => ['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key) && event.preventDefault(), { signal });
// Resend: a countdown that rolls, then a link
const seconds = Number(root.dataset.resend) || 30;
let left = 0;
let timer = 0;
const roll = (value) => {
if (!count) return;
const prev = count.lastElementChild;
const span = Object.assign(document.createElement('span'), { textContent: value });
count.append(span);
if (!prev) return;
if (reduceMotion) return prev.remove();
const timing = { duration: 350, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' };
prev.animate([{ transform: 'none' }, { transform: 'translateY(-110%)' }], { ...timing, fill: 'forwards' }).finished.then(() => prev.remove());
span.animate([{ transform: 'translateY(110%)' }, { transform: 'none' }], timing);
};
const tick = () => {
left -= 1;
if (left <= 0) {
clearInterval(timer);
resend.disabled = false;
resend.replaceChildren('Resend code');
return;
}
roll(left);
};
const start = () => {
if (!resend) return;
left = seconds;
resend.disabled = true;
if (count) {
count.replaceChildren();
roll(left);
resend.replaceChildren('Resend in ', count, 's');
}
clearInterval(timer);
timer = setInterval(tick, 1000);
};
signal.addEventListener('abort', () => clearInterval(timer));
resend?.addEventListener('click', () => {
root.dispatchEvent(new CustomEvent('kodza:resend', { bubbles: true }));
start();
input.focus();
}, { signal });
render();
start();
}
// Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
// event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
// markup back. For themes and apps that swap content without a page load.
const instances = new WeakMap();
const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
const init = (scope) => find(scope).forEach((root) => {
if (root.classList.contains('is-ready')) return; // already running, maybe from another copy of this script
const controller = new AbortController();
instances.set(root, { controller, html: root.innerHTML, attrs: [...root.attributes].map((a) => [a.name, a.value]) });
mount(root, controller.signal);
root.classList.add('is-ready');
});
const destroy = (scope) => find(scope).forEach((root) => {
const instance = instances.get(root);
if (!instance) return;
instances.delete(root);
instance.controller.abort();
[...root.attributes].forEach((a) => root.removeAttribute(a.name));
instance.attrs.forEach(([name, value]) => root.setAttribute(name, value));
root.innerHTML = instance.html;
});
document.addEventListener('kodza:init', (event) => init(event.detail?.root));
document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
init();
// Loaded in <head>, the script runs before the markup exists: once more when it is parsed
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
'use client';
// Kodza — OTP Input, for React. From https://kodza.dev/components/otp-input/
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Next to this file: otp-input.css and otp-input.js, the component's CSS and JS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
// The script changes this markup once React has rendered it (it adds layers, splits text, moves
// nodes), so React must not patch it afterwards. When a prop changes, the key gives React a fresh
// element: kodza:destroy stops the script on the old one, kodza:init starts it on the new one.
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './otp-input.css';
export type OTPInputProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "vars" | "length" | "onComplete" | "onResend"> & {
/** data-length on the root (default: 6) */
length?: "6";
/** Settings, as CSS variables on the root */
vars?: {
/** default: #ffffff */
"--kz-otp-bg"?: string | number;
/** default: #0a0a0a */
"--kz-otp-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.6) */
"--kz-otp-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.14) */
"--kz-otp-line"?: string | number;
/** default: rgba(10, 10, 10, 0.06) */
"--kz-otp-soft"?: string | number;
/** default: #0a0a0a */
"--kz-otp-ink"?: string | number;
/** default: #ffffff */
"--kz-otp-on-ink"?: string | number;
/** default: 52px */
"--kz-otp-cell"?: string | number;
/** default: 60px */
"--kz-otp-height"?: string | number;
/** default: 8px */
"--kz-otp-gap"?: string | number;
/** default: 12px */
"--kz-otp-radius"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-otp-ease"?: string | number;
};
/** "kodza:complete", fired by the script (bubbles from the root) */
onComplete?: (event: CustomEvent) => void;
/** "kodza:resend", fired by the script (bubbles from the root) */
onResend?: (event: CustomEvent) => void;
className?: string;
};
export function OTPInput({ length = "6", vars, onComplete, onResend, className, style, ...rest }: OTPInputProps) {
const ref = useRef<ComponentRef<'div'>>(null);
const classes = ["kodza-otp", className].filter(Boolean).join(' ');
// Everything the markup is made of: when it changes, React gets a fresh element (see the top)
const key = JSON.stringify([classes, length, vars, style, rest]);
// Starts the script on this element once React has rendered it, stops it when it goes
useEffect(() => {
const root = ref.current;
let live = true;
// @ts-ignore: a plain script, no types to find
import('./otp-input.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
useEffect(() => {
const root = ref.current;
if (!root || !onComplete) return;
const listener = (event: Event) => onComplete(event as CustomEvent);
root.addEventListener("kodza:complete", listener);
return () => root.removeEventListener("kodza:complete", listener);
}, [onComplete, key]);
useEffect(() => {
const root = ref.current;
if (!root || !onResend) return;
const listener = (event: Event) => onResend(event as CustomEvent);
root.addEventListener("kodza:resend", listener);
return () => root.removeEventListener("kodza:resend", listener);
}, [onResend, key]);
return (
<div key={key} {...rest} className={classes} data-length={length} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<p className="kodza-otp__label">Enter the code we sent to florent@kodza.dev</p>
<div className="kodza-otp__field">
<input className="kodza-otp__input" type="text" inputMode="numeric" autoComplete="one-time-code" maxLength={6} aria-label="Six-digit code" />
<div className="kodza-otp__cells" aria-hidden="true" />
</div>
<p className="kodza-otp__foot">
<span className="kodza-otp__done" aria-live="polite" />
{' '}
<span className="kodza-otp__again">Didn't get it? <button className="kodza-otp__resend" type="button">Resend in <span className="kodza-otp__count">30</span>s</button></span>
</p>
</div>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.