Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

OTP

A one-time-code verification screen composed from touchcn components.

A phone or email verification screen: a back control in the navbar, a headline, a prop-driven masked target line, a segmented six-digit code field that auto-submits the moment the last digit is entered, and a resend control gated by a 60-second countdown. The code and the timer are local screen state; wire the actual verification and resend calls from the parent.

Installation

npx touchcn add otp

This also adds the components the block composes:

button · input-otp · navbar · page

Usage

<tcn-otp-block target="•••• 1234" (verify)="check($event)" (resend)="resend()" />
import { TcnOtpBlock } from '@/components/blocks/otp';

<TcnOtpBlock target="•••• 1234" onVerify={(code) => check(code)} onResend={resend} />
<script setup lang="ts">
import { TcnOtpBlock } from '@/components/blocks/otp';
</script>

<template>
  <TcnOtpBlock target="•••• 1234" @verify="(code) => check(code)" @resend="resend" />
</template>

verify fires with the full code string as soon as every slot is filled. target is the masked destination shown in the body copy. resend fires when the countdown reaches zero and the user taps Resend (the timer restarts).

Source · Angular
export * from './tcn-otp-block';
import { Component, DestroyRef, inject, input, output, signal } from '@angular/core';
import { TcnButton } from '@/components/ui/button';
import { TcnInputOtp } from '@/components/ui/input-otp';
import { TcnNavbar } from '@/components/ui/navbar';
import { TcnPage } from '@/components/ui/page';

const RESEND_SECONDS = 60;

/**
 * Phone/email verification screen — a segmented 6-digit code field that
 * auto-submits (`verify`) the moment every slot is filled, plus a resend control
 * gated by a 60-second countdown. The masked `target` line is prop-driven. Screen
 * state (the code and the countdown timer) is local; wire the actual verification
 * and resend calls from the parent.
 */
@Component({
  selector: 'tcn-otp-block',
  imports: [TcnPage, TcnNavbar, TcnInputOtp, TcnButton],
  template: `
    <tcn-page>
      <tcn-navbar>
        <button navbar-leading type="button" class="tcn-navbar-back" aria-label="Back" (click)="back.emit()">
          <svg class="if-ios" width="11" height="18" viewBox="0 0 12 20" fill="none" aria-hidden="true">
            <path d="M10 2 2 10l8 8" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
          <svg class="if-md" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path d="M20 12H4m0 0 6-6m-6 6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
        </button>
      </tcn-navbar>

      <div class="mx-auto flex min-h-full max-w-[430px] flex-col justify-center px-5 pt-20 pb-12">
        <div class="mb-8 text-center">
          <h1 class="text-2xl font-semibold">Verify your number</h1>
          <p class="mt-2 text-sm text-[var(--color-on-surface-variant)]">
            Enter the 6-digit code we sent to {{ target() }}
          </p>
        </div>

        <div class="flex justify-center">
          <tcn-input-otp
            ariaLabel="Verification code"
            [groupSize]="3"
            [(value)]="code"
            (completed)="verify.emit($event)"
          />
        </div>

        <div class="mt-8 flex flex-col items-center gap-1">
          @if (secondsLeft() > 0) {
            <p class="text-sm text-[var(--color-on-surface-variant)]">
              Resend code in {{ secondsLeft() }}s
            </p>
          } @else {
            <button tcnButton variant="ghost" size="sm" type="button" (click)="onResend()">Resend code</button>
          }
          <button tcnButton variant="ghost" size="sm" type="button" (click)="changeNumber.emit()">
            Changed your number?
          </button>
        </div>
      </div>
    </tcn-page>
  `,
})
export class TcnOtpBlock {
  /** Masked destination shown in the body copy, e.g. `•••• 1234`. */
  readonly target = input('•••• 1234');
  /** Emits the full code the moment the last digit is entered. */
  readonly verify = output<string>();
  /** Emits when the countdown expires and the user taps Resend. */
  readonly resend = output<void>();
  readonly changeNumber = output<void>();
  readonly back = output<void>();

  protected readonly code = signal('');
  protected readonly secondsLeft = signal(RESEND_SECONDS);

  private timer: ReturnType<typeof setInterval> | undefined;

  constructor() {
    this.startCountdown();
    inject(DestroyRef).onDestroy(() => this.clearTimer());
  }

  protected onResend(): void {
    this.startCountdown();
    this.resend.emit();
  }

  private startCountdown(): void {
    this.clearTimer();
    this.secondsLeft.set(RESEND_SECONDS);
    this.timer = setInterval(() => {
      this.secondsLeft.update((value) => Math.max(0, value - 1));
      if (this.secondsLeft() === 0) {
        this.clearTimer();
      }
    }, 1000);
  }

  private clearTimer(): void {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = undefined;
    }
  }
}
Source · React
export * from './tcn-otp-block';
import { useEffect, useRef, useState } from 'react';
import { TcnButton } from '@/components/ui/button';
import { TcnInputOtp } from '@/components/ui/input-otp';
import { TcnNavbar } from '@/components/ui/navbar';
import { TcnPage } from '@/components/ui/page';

const RESEND_SECONDS = 60;

export interface TcnOtpBlockProps {
  /** Masked destination shown in the body copy, e.g. `•••• 1234`. */
  target?: string;
  /** Fires with the full code the moment the last digit is entered. */
  onVerify?(code: string): void;
  /** Fires when the countdown expires and the user taps Resend. */
  onResend?(): void;
  onChangeNumber?(): void;
  onBack?(): void;
}

/**
 * Phone/email verification screen — a segmented 6-digit code field that
 * auto-submits (`onVerify`) the moment every slot is filled, plus a resend
 * control gated by a 60-second countdown. The masked `target` line is prop-driven.
 * Screen state (the code and the countdown timer) is local; wire the actual
 * verification and resend calls from the parent.
 */
export function TcnOtpBlock({
  target = '•••• 1234',
  onVerify,
  onResend,
  onChangeNumber,
  onBack,
}: TcnOtpBlockProps) {
  const [code, setCode] = useState('');
  const [secondsLeft, setSecondsLeft] = useState(RESEND_SECONDS);
  const timerRef = useRef<ReturnType<typeof setInterval>>(undefined);

  const startCountdown = () => {
    clearInterval(timerRef.current);
    setSecondsLeft(RESEND_SECONDS);
    timerRef.current = setInterval(() => {
      setSecondsLeft((value) => {
        if (value <= 1) {
          clearInterval(timerRef.current);
          return 0;
        }
        return value - 1;
      });
    }, 1000);
  };

  useEffect(() => {
    startCountdown();
    return () => clearInterval(timerRef.current);
  }, []);

  const handleResend = () => {
    startCountdown();
    onResend?.();
  };

  const back = (
    <button type="button" className="tcn-navbar-back" aria-label="Back" onClick={() => onBack?.()}>
      <svg className="if-ios" width="11" height="18" viewBox="0 0 12 20" fill="none" aria-hidden="true">
        <path d="M10 2 2 10l8 8" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
      </svg>
      <svg className="if-md" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <path d="M20 12H4m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
      </svg>
    </button>
  );

  return (
    <TcnPage>
      <TcnNavbar leading={back} />
      <div className="mx-auto flex min-h-full max-w-[430px] flex-col justify-center px-5 pt-20 pb-12">
        <div className="mb-8 text-center">
          <h1 className="text-2xl font-semibold">Verify your number</h1>
          <p className="mt-2 text-sm text-[var(--color-on-surface-variant)]">
            Enter the 6-digit code we sent to {target}
          </p>
        </div>

        <div className="flex justify-center">
          <TcnInputOtp ariaLabel="Verification code" groupSize={3} value={code} onValueChange={setCode} onComplete={(value) => onVerify?.(value)} />
        </div>

        <div className="mt-8 flex flex-col items-center gap-1">
          {secondsLeft > 0 ? (
            <p className="text-sm text-[var(--color-on-surface-variant)]">Resend code in {secondsLeft}s</p>
          ) : (
            <TcnButton variant="ghost" size="sm" onClick={handleResend}>
              Resend code
            </TcnButton>
          )}
          <TcnButton variant="ghost" size="sm" onClick={() => onChangeNumber?.()}>
            Changed your number?
          </TcnButton>
        </div>
      </div>
    </TcnPage>
  );
}
Source · Vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { TcnButton } from '@/components/ui/button';
import { TcnInputOtp } from '@/components/ui/input-otp';
import { TcnNavbar } from '@/components/ui/navbar';
import { TcnPage } from '@/components/ui/page';

const RESEND_SECONDS = 60;

/**
 * Phone/email verification screen — a segmented 6-digit code field that
 * auto-submits (`verify`) the moment every slot is filled, plus a resend control
 * gated by a 60-second countdown. The masked `target` line is prop-driven.
 * Screen state (the code and the countdown timer) is local; wire the actual
 * verification and resend calls from the parent.
 */
withDefaults(defineProps<{
  /** Masked destination shown in the body copy, e.g. `•••• 1234`. */
  target?: string;
}>(), { target: '•••• 1234' });

const emit = defineEmits<{
  /** Fires with the full code the moment the last digit is entered. */
  verify: [code: string];
  /** Fires when the countdown expires and the user taps Resend. */
  resend: [];
  changeNumber: [];
  back: [];
}>();

const code = ref('');
const secondsLeft = ref(RESEND_SECONDS);
let timer: ReturnType<typeof setInterval> | undefined;

const startCountdown = (): void => {
  clearInterval(timer);
  secondsLeft.value = RESEND_SECONDS;
  timer = setInterval(() => {
    if (secondsLeft.value <= 1) {
      clearInterval(timer);
      secondsLeft.value = 0;
      return;
    }
    secondsLeft.value -= 1;
  }, 1000);
};

const handleResend = (): void => {
  startCountdown();
  emit('resend');
};

onMounted(startCountdown);
onBeforeUnmount(() => clearInterval(timer));
</script>

<template>
  <TcnPage>
    <TcnNavbar>
      <template #leading>
        <button type="button" class="tcn-navbar-back" aria-label="Back" @click="emit('back')">
          <svg class="if-ios" width="11" height="18" viewBox="0 0 12 20" fill="none" aria-hidden="true">
            <path d="M10 2 2 10l8 8" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
          <svg class="if-md" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path d="M20 12H4m0 0 6-6m-6 6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
        </button>
      </template>
    </TcnNavbar>
    <div class="mx-auto flex min-h-full max-w-[430px] flex-col justify-center px-5 pt-20 pb-12">
      <div class="mb-8 text-center">
        <h1 class="text-2xl font-semibold">Verify your number</h1>
        <p class="mt-2 text-sm text-[var(--color-on-surface-variant)]">
          Enter the 6-digit code we sent to {{ target }}
        </p>
      </div>

      <div class="flex justify-center">
        <TcnInputOtp
          v-model="code"
          aria-label="Verification code"
          :group-size="3"
          @complete="(value) => emit('verify', value)"
        />
      </div>

      <div class="mt-8 flex flex-col items-center gap-1">
        <p v-if="secondsLeft > 0" class="text-sm text-[var(--color-on-surface-variant)]">
          Resend code in {{ secondsLeft }}s
        </p>
        <TcnButton v-else variant="ghost" size="sm" @click="handleResend">Resend code</TcnButton>
        <TcnButton variant="ghost" size="sm" @click="emit('changeNumber')">Changed your number?</TcnButton>
      </div>
    </div>
  </TcnPage>
</template>
export { default as TcnOtpBlock } from './TcnOtpBlock.vue';

Last updated on July 24, 2026

Was this page helpful?