Campo de código OTP
Una casilla por dígito con avance automático, borrado y pegado del código.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
/**
* Campo de código de un solo uso (OTP): una casilla por dígito, avance automático, borrar hacia atrás y pegar el código completo.
*
* <OtpField length={6} onComplete={(code) => verify(code)} />
*
* Es un grupo de inputs numéricos con autoComplete="one-time-code" en el primero, así que iOS y Android
* ofrecen el código recibido por SMS.
*/
import { useRef, useState, type ClipboardEvent, type KeyboardEvent } from "react";
export type OtpFieldProps = {
length?: number;
/** Código inicial (solo dígitos). */
defaultValue?: string;
onChange?: (value: string) => void;
/** Se llama una vez, cuando todas las casillas tienen un dígito. */
onComplete?: (value: string) => void;
/** Pinta las casillas como error. */
invalid?: boolean;
label?: string;
className?: string;
};
const CSS = `
.gco-group { --gco-accent: #7dd3a8; --gco-error: #ff6b6b; display: inline-flex; gap: 10px; }
.gco-cell { width: 52px; height: 64px; box-sizing: border-box; padding: 0; border: 1.5px solid #2c313b; border-radius: 14px; outline: none; background: #161a22; color: #f2f4f8;
font-family: inherit; font-size: 28px; font-weight: 600; font-variant-numeric: tabular-nums; text-align: center; caret-color: var(--gco-accent);
transition: border-color .2s, background-color .2s, transform .25s cubic-bezier(.34, 1.7, .5, 1), box-shadow .2s; }
.gco-cell:focus { border-color: var(--gco-accent); background: #1b2029; box-shadow: 0 0 0 4px rgba(125, 211, 168, .16); transform: translateY(-2px); }
.gco-cell[data-filled="true"] { border-color: #4b5361; }
.gco-group[data-invalid="true"] .gco-cell { border-color: var(--gco-error); animation: gco-shake .4s; }
.gco-group[data-invalid="true"] .gco-cell:focus { box-shadow: 0 0 0 4px rgba(255, 107, 107, .18); }
@keyframes gco-shake { 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } }
@media (max-width: 420px) { .gco-group { gap: 6px; } .gco-cell { width: 42px; height: 54px; font-size: 24px; } }
@media (prefers-reduced-motion: reduce) { .gco-cell { transition: none; } .gco-group[data-invalid="true"] .gco-cell { animation: none; } }
`;
export function OtpField({ length = 6, defaultValue = "", onChange, onComplete, invalid, label = "Código de verificación", className }: OtpFieldProps) {
const [digits, setDigits] = useState<string[]>(() => Array.from({ length }, (_, index) => defaultValue.replace(/\D/g, "")[index] ?? ""));
const refs = useRef<(HTMLInputElement | null)[]>([]);
function commit(next: string[]) {
setDigits(next);
const value = next.join("");
onChange?.(value);
if (value.length === length) onComplete?.(value);
}
function focusAt(index: number) {
const target = refs.current[Math.max(0, Math.min(length - 1, index))];
target?.focus();
target?.select();
}
function type(index: number, raw: string) {
const clean = raw.replace(/\D/g, "");
if (!clean) return;
const next = [...digits];
// Several digits at once (autofill from SMS) spread over the next cells.
clean.split("").slice(0, length - index).forEach((digit, offset) => { next[index + offset] = digit; });
commit(next);
focusAt(index + clean.length);
}
function onKeyDown(index: number, event: KeyboardEvent<HTMLInputElement>) {
if (event.key === "Backspace") {
event.preventDefault();
const next = [...digits];
if (next[index]) { next[index] = ""; commit(next); }
else if (index > 0) { next[index - 1] = ""; commit(next); focusAt(index - 1); }
} else if (event.key === "ArrowLeft") { event.preventDefault(); focusAt(index - 1); }
else if (event.key === "ArrowRight") { event.preventDefault(); focusAt(index + 1); }
}
function onPaste(index: number, event: ClipboardEvent<HTMLInputElement>) {
event.preventDefault();
type(index, event.clipboardData.getData("text"));
}
return (
<>
<style>{CSS}</style>
<div role="group" aria-label={label} className={["gco-group", className].filter(Boolean).join(" ")} data-invalid={invalid ? "true" : undefined}>
{digits.map((digit, index) => (
<input
key={index}
ref={(element) => { refs.current[index] = element; }}
className="gco-cell"
inputMode="numeric"
autoComplete={index === 0 ? "one-time-code" : "off"}
aria-label={`Dígito ${index + 1} de ${length}`}
aria-invalid={invalid ? true : undefined}
value={digit}
data-filled={digit ? "true" : "false"}
onChange={(event) => type(index, event.target.value)}
onKeyDown={(event) => onKeyDown(index, event)}
onPaste={(event) => onPaste(index, event)}
onFocus={(event) => event.target.select()}
/>
))}
</div>
</>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/codigo-otpEjemplo de uso de Campo de código OTP
Después de copiar otp-field.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { OtpField } from "./otp-field";
export default function Ejemplo() {
return <OtpField defaultValue="Ejemplo" invalid />;
}Propiedades de OtpField
OtpField acepta 7 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
length? | number | Opcional. |
defaultValue? | string | Código inicial (solo dígitos). |
onChange? | (value: string) => void | Opcional. |
onComplete? | (value: string) => void | Se llama una vez, cuando todas las casillas tienen un dígito. |
invalid? | boolean | Pinta las casillas como error. |
label? | string | Opcional. |
className? | string | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 108 líneas (5 KB) y define 1 animación con @keyframes. Esto es lo que hace Campo de código OTP por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Declara roles ARIA para lectores de pantalla: group.
- Responde al teclado, no solo al ratón.
- Es un componente de cliente de Next.js: incluye la directiva "use client".
Acerca del producto
Casillas para un código de verificación: escribe y salta a la siguiente, Retroceso vuelve a la anterior, las flechas navegan y pegar el código completo lo reparte. El primer campo usa autocomplete one-time-code para que el teléfono ofrezca el SMS.
Incluye
Un solo archivo React + TypeScript, con los estilos dentro. No usa Tailwind ni bibliotecas externas.
- Longitud configurable (por defecto 6)
- Pegado y autocompletado de SMS
- onComplete se llama una sola vez al llenarse
- Estado de error con animación breve
Cómo usarlo
Pasa length y onComplete. Marca invalid cuando el código no sea correcto.