Forms
OTPOrbit
A one-time-code field. A light orbits the box you are typing in and each digit pops in. One real input underneath, so paste and SMS autofill just work.
Download
- React
- Tailwind CSS v4
- motion
components/ui/otp-orbit.tsx
"use client";
import { useId, useState, type CSSProperties, type SyntheticEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type OTPOrbitProps = {
/** Number of characters in the code. */
length?: number;
/** Called on every change with the code so far. */
onChange?: (code: string) => void;
/** Called once when the last box is filled. */
onComplete?: (code: string) => void;
/** Field name shown above the boxes. */
label?: string;
/** Small help text under the boxes. */
hint?: string;
/** Digits only (number keypad on phones). false = letters and digits. */
numeric?: boolean;
/** Form field name, so the code is sent with a normal form. */
name?: string;
/** Colour of the orbiting light and filled boxes. */
accent?: string;
/** Wrapper look (width etc.). */
className?: string;
};
/* OTPOrbit: a one-time-code field. A light orbits the box you are typing in, and each character pops in. One real input under the boxes, so paste and SMS autofill just work. */
export function OTPOrbit({
length = 6,
onChange,
onComplete,
label = "Verification code",
hint,
numeric = true,
name,
accent = "#a78bfa",
className = "w-full max-w-sm",
}: OTPOrbitProps) {
const reduce = useReducedMotion();
const id = useId();
const [code, setCode] = useState("");
const [focused, setFocused] = useState(false);
const active = Math.min(code.length, length - 1);
const change = (raw: string) => {
const next = (numeric ? raw.replace(/\D/g, "") : raw.replace(/[^a-z0-9]/gi, "").toUpperCase()).slice(0, length);
if (next === code) return;
setCode(next);
onChange?.(next);
if (next.length === length) onComplete?.(next);
};
// Keep the caret at the end so typing and Backspace always work on the last box.
const pinCaret = (e: SyntheticEvent<HTMLInputElement>) => {
const el = e.currentTarget;
const end = el.value.length;
if (el.selectionStart !== end || el.selectionEnd !== end) el.setSelectionRange(end, end);
};
return (
<div className={className} style={{ "--otp": accent } as CSSProperties}>
<label htmlFor={id} className="mb-3 block text-sm font-medium text-white/80">
{label}
</label>
<div className="relative flex gap-2 sm:gap-3">
{Array.from({ length }, (_, i) => {
const ch = code[i];
const on = focused && i === active;
return (
<div
key={i}
aria-hidden
className={`relative grid aspect-[4/5] min-w-0 flex-1 basis-0 place-items-center overflow-hidden rounded-xl border bg-[#0f0d1c] transition-colors duration-200 motion-reduce:transition-none ${
on && reduce ? "border-[var(--otp)]" : ch ? "border-[color-mix(in_srgb,var(--otp)_45%,transparent)]" : "border-white/12"
}`}
style={{ maxWidth: 52 }}
>
{on && !reduce && (
<>
<motion.span
className="absolute top-1/2 left-1/2 size-[220%] -translate-x-1/2 -translate-y-1/2"
style={{ background: "conic-gradient(from 0deg, transparent 0 70%, var(--otp))" }}
animate={{ rotate: 360 }}
transition={{ duration: 1.6, ease: "linear", repeat: Infinity }}
/>
<span className="absolute inset-[1.5px] rounded-[10px] bg-[#0f0d1c]" />
</>
)}
<AnimatePresence initial={false}>
{ch && (
<motion.span
key={ch + i}
initial={reduce ? false : { scale: 0.4, y: 8, opacity: 0 }}
animate={{ scale: 1, y: 0, opacity: 1 }}
exit={reduce ? { opacity: 0, transition: { duration: 0 } } : { scale: 0.4, opacity: 0, transition: { duration: 0.12 } }}
transition={{ type: "spring", stiffness: 520, damping: 24 }}
className="relative col-start-1 row-start-1 font-mono text-xl font-semibold text-white tabular-nums"
>
{ch}
</motion.span>
)}
</AnimatePresence>
{on && !ch && <span className="relative col-start-1 row-start-1 h-6 w-0.5 animate-pulse rounded-full bg-[var(--otp)] motion-reduce:animate-none" />}
</div>
);
})}
<input
id={id}
name={name}
value={code}
onChange={(e) => change(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onSelect={pinCaret}
maxLength={length}
inputMode={numeric ? "numeric" : "text"}
pattern={numeric ? `\\d{${length}}` : undefined}
autoComplete="one-time-code"
autoCapitalize="characters"
spellCheck={false}
aria-describedby={hint ? `${id}-hint` : undefined}
className="absolute inset-0 h-full w-full cursor-text bg-transparent text-base text-transparent caret-transparent opacity-0 outline-none selection:bg-transparent"
/>
</div>
{hint && (
<p id={`${id}-hint`} className="mt-2 px-1 text-xs text-white/45">
{hint}
</p>
)}
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/otp-orbit.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/otp-orbit-demo.tsx
"use client"; import { useState } from "react"; import { OTPOrbit } from "@/components/ui/otp-orbit"; export default function OTPOrbitExample() { const [done, setDone] = useState(""); return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-5"> <div className="w-full max-w-sm"> <OTPOrbit hint="Sent to •••• 4821" onChange={(c) => c.length < 6 && setDone("")} onComplete={setDone} className="w-full" /> <p aria-live="polite" className="mt-3 h-5 text-sm text-emerald-300"> {done && `Code ${done} entered`} </p> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| length | number | 6 | Number of characters. |
| onChange | (code: string) => void | — | Called on every change. |
| onComplete | (code: string) => void | — | Called when the last box is filled. |
| label | string | "Verification code" | Field name above the boxes. |
| hint | string | — | Help text under the boxes. |
| numeric | boolean | true | Digits only. false = letters and digits. |
| name | string | — | Form field name. |
| accent | string | "#a78bfa" | Orbit light colour. |
| className | string | "w-full max-w-sm" | Wrapper look. |