Ir al contenido
Evaluar mi proyecto
Componentes · FormulariosDisponible

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.

otp-field.tsx108 líneas
Descargar
"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.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/codigo-otp

Ejemplo 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.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
length?numberOpcional.
defaultValue?stringCódigo inicial (solo dígitos).
onChange?(value: string) => voidOpcional.
onComplete?(value: string) => voidSe llama una vez, cuando todas las casillas tienen un dígito.
invalid?booleanPinta las casillas como error.
label?stringOpcional.
className?stringOpcional.

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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React · Next.js (quita la directiva use client fuera de Next.js) · Sin Tailwind

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.