OTP verification
The email/two-factor verification step: an oversized, centered OTP field styled as four circular digits, a full-width pill Confirm button, and a real countdown before "Resend code" becomes clickable — no dead-end if the email doesn't arrive.
otp-verify.tsx
Built with
How it’s built
- 01
type="otp"handles paste-across, arrow-key movement, backspace, and digit-only input for you — this pattern only adds layout on top. - 02
radius="full"turns the segments circular; OtpField has noclassNamesslot to size them individually, so the circle diameter and centered gap come from a Tailwind arbitrary-descendant selector ([&_.oksOtpFieldSegment]:!h-16) reaching into its fixed internal class names. - 03The
labelis kept in the DOM for screen readers but visually hidden ([&_.oksTextFieldLabel]:sr-only) — the heading above already states what the field is for, so a second visible label would be redundant. - 04"Check your email" is a real
PageTitle, with the bold email address passed as JSX insubtitlerather than plain text.classNames={{ base: "flex-col items-center" }}keeps the title above the subtitle (not beside it,PageTitle's own default) while also centering both, matching this screen's centered layout. - 05The countdown is a real
setTimeoutloop, not a static string — it counts down to 0 and then swaps the text for an actualButton variant="link""Resend code" button, so the resend affordance is never a dead end. - 06"Resend code" and "back" are real
Button variant="link"components, not a raw<button>/<a>—href(with notype) makesButtonrender as an anchor automatically, andno-underlineoverrides the variant's default underline. - 07
Button radius="full"gives the pill-shaped Confirm button;color="primary"keeps it on the same brand color as every other Auth pattern's primary action.
Source
One file · copy & pasteStyling: this source uses Tailwind utility classes and this site’s colour tokens (for example text-ink, bg-paper and border-line). The oks-ui components render without them; swap those classes for your own.
import { useEffect, useState } from "react";
import { Button } from "oks-ui/button";
import { Form, FormFieldSet } from "oks-ui/form-field-set";
import { PageTitle } from "oks-ui/page-title";
const ArrowLeftIcon = () => {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M13 8H3M3 8l4-4M3 8l4 4"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
const RESEND_SECONDS = 41;
const OtpVerify = () => {
const [secondsLeft, setSecondsLeft] = useState(RESEND_SECONDS);
useEffect(() => {
if (secondsLeft <= 0) return;
const id = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
return () => clearTimeout(id);
}, [secondsLeft]);
const mm = String(Math.floor(secondsLeft / 60)).padStart(2, "0");
const ss = String(secondsLeft % 60).padStart(2, "0");
return (
// @container has to live on its own wrapper, not the element the
// @2xl:py-24 below conditions — an element can't query its own
// container for its own styling (circular per the CSS Containment
// spec), so it would silently be evaluated against the wrong
// (ancestor) container and never apply.
<div className="@container w-full">
<div className="flex w-full items-center justify-center bg-paper-raised px-6 py-16 @2xl:py-24">
<div className="flex w-full max-w-sm flex-col items-center gap-8 text-center">
<PageTitle
as="h1"
title="Check your email"
subtitle={
<>
Please enter the four digit verification code we sent to{" "}
<span className="font-semibold text-ink">example@gmail.com</span>
</>
}
classNames={{
base: "flex-col items-center gap-3",
title: "text-[1.9rem] font-bold tracking-tight text-ink @2xl:text-[2.1rem]",
subtitle: "mt-0 text-[0.95rem] leading-relaxed text-ink-muted",
}}
/>
<Form onSubmit={(data) => console.log(data)} className="flex w-full flex-col items-center gap-8">
{/* A visually-hidden label keeps the field accessible without the
literal "Verification code" text the reference doesn't show;
the arbitrary Tailwind selectors reach into OtpField's fixed
internal class names (it has no classNames slot prop) to make
each segment a fixed-size circle instead of an equal-width box. */}
<FormFieldSet
type="otp"
name="code"
label="Verification code"
length={4}
radius="full"
className="w-auto [&_.oksOtpFieldSegment]:!h-16 [&_.oksOtpFieldSegment]:!w-16 [&_.oksOtpFieldSegment]:!flex-none [&_.oksOtpFieldSegment]:text-lg [&_.oksOtpFieldSegment]:font-semibold [&_.oksOtpFieldSegments]:justify-center [&_.oksOtpFieldSegments]:gap-4 [&_.oksTextFieldLabel]:sr-only"
validation={{ rules: { required: true } }}
/>
<Button type="submit" color="primary" radius="full" size="lg" fullWidth>
Confirm
</Button>
</Form>
<p className="text-[0.9rem] text-ink-muted">
{secondsLeft > 0 ? (
<>
Didn’t get the email? Resend in {mm}:{ss}
</>
) : (
<>
Didn’t get the email?{" "}
<Button
type="button"
onClick={() => setSecondsLeft(RESEND_SECONDS)}
variant="link"
size="sm"
className="h-auto p-0 align-baseline font-semibold text-accent no-underline"
>
Resend code
</Button>
</>
)}
</p>
<Button
href="/sign-in"
variant="link"
size="sm"
startContent={<ArrowLeftIcon />}
className="h-auto p-0 text-[0.9rem] font-medium text-ink-muted no-underline"
>
back
</Button>
</div>
</div>
</div>
);
};
export default OtpVerify;