Auth
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.
How it's built
`type="otp"` handles paste-across, arrow-key movement, backspace, and digit-only input for you — this pattern only adds layout on top.
`radius="full"` turns the segments circular; OtpField has no `classNames` slot 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.
The `label` is 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.
"Check your email" is a real `PageTitle`, with the bold email address passed as JSX in `subtitle` rather 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.
The countdown is a real `setTimeout` loop, not a static string — it counts down to 0 and then swaps the text for an actual `Button variant="link"` "Resend code" button, so the resend affordance is never a dead end.
"Resend code" and "back" are real `Button variant="link"` components, not a raw `<button>`/`<a>` — `href` (with no `type`) makes `Button` render as an anchor automatically, and `no-underline` overrides the variant's default underline.
`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 & pasteimport { 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";
function 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;
export function 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>
);
}