Skip to content
oks-ui

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.

otp-verify.tsx

How it's built

1

`type="otp"` handles paste-across, arrow-key movement, backspace, and digit-only input for you — this pattern only adds layout on top.

2

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

3

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.

4

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

5

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.

6

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

7

`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 & paste
otp-verify.tsx
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";

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&rsquo;t get the email? Resend in {mm}:{ss}
            </>
          ) : (
            <>
              Didn&rsquo;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>
  );
}

More patterns

Auth
Sign-in screenA split-screen sign-in with a feature panel, Google SSO, and remember-me.
Auth
Sign-up screenA dark canvas with a feature pitch, SSO row, and a real registration form.
Auth
Forgot passwordA single email field beside an original customer-quote panel.