Two-factor / OTP block

The verification step of a sign-in flow: SvOtpInput (6 digits, auto-advance + onComplete), a resend cooldown timer, and the waiting -> verifying -> verified states. Enter 000000 to see the error path.

A live, editable Svelte 5 component example from the SvGrid gallery (Blocks). Read the SvGrid UI component docs for the full API.

About this example

A copy-paste two-factor verification block built from the SvGrid UI kit in Svelte 5: a centred card with SvOtpInput for six digits that auto-advances and fires onComplete, a resend cooldown timer, verify and back actions, and the waiting, verifying and verified states with a loading button. Enter 000000 to see the error path.

Two-factor verification block - the OTP step of a sign-in flow. A centered card with SvOtpInput (6 digits, auto-advance + onComplete), a resend timer, and verify / back actions. Shows the "waiting -> verifying -> done" states. Pure UI-kit, --sg-* themed.

Imports, features and API used

Imports: @svgrid/grid

Frequently asked questions

How does verification start?

onComplete fires when the sixth digit is entered and calls verify with the code, so most users never press the button; the button remains for a partial code.

How does the resend timer work?

A countdown in state decrements every second after a resend; the link is disabled and shows the remaining seconds until it reaches zero.

How is an error shown?

A rejected code sets the status to error, marks the input invalid with a message and clears it for another attempt; the demo rejects 000000.

Related documentation

Source code (422-block-otp.svelte)

<script lang="ts">
  /**
   * Two-factor verification block - the OTP step of a sign-in flow. A centered
   * card with SvOtpInput (6 digits, auto-advance + onComplete), a resend timer,
   * and verify / back actions. Shows the "waiting -> verifying -> done" states.
   * Pure UI-kit, --sg-* themed.
   */
  import {
    SvCard, SvOtpInput, SvButton, SvBadge, SvDivider, SvToaster, toast,
  } from '@svgrid/grid'

  let code = $state('')
  let status = $state<'idle' | 'verifying' | 'done'>('idle')
  let cooldown = $state(0)

  const dest = '[email protected]'

  function verify(value = code) {
    if (value.length < 6) { toast('Enter all 6 digits', { variant: 'warning' }); return }
    status = 'verifying'
    setTimeout(() => {
      if (value === '000000') { status = 'idle'; toast.error('Invalid code, try again'); return }
      status = 'done'
      toast.success('Identity verified')
    }, 900)
  }

  let timer: ReturnType<typeof setInterval> | undefined
  function resend() {
    if (cooldown > 0) return
    toast('New code sent to ' + dest)
    cooldown = 30
    timer = setInterval(() => {
      cooldown -= 1
      if (cooldown <= 0 && timer) clearInterval(timer)
    }, 1000)
  }
</script>

<div class="otp-wrap">
  <SvCard>
    <div class="head">
      <span class="shield">🛡</span>
      <h2>Verify it's you</h2>
      <p class="muted">We sent a 6-digit code to <strong>{dest}</strong>.</p>
    </div>

    {#if status === 'done'}
      <div class="done">
        <span class="check">✓</span>
        <div>
          <strong>Verified</strong>
          <div class="muted">Redirecting to your dashboard...</div>
        </div>
      </div>
    {:else}
      <div class="otp">
        <SvOtpInput
          bind:value={code}
          length={6}
          autofocus
          onComplete={(v) => verify(v)}
        />
      </div>

      <SvButton
        variant="primary"
        block
        loading={status === 'verifying'}
        onclick={() => verify()}
      >Verify</SvButton>

      <div class="resend">
        <span class="muted">Didn't get a code?</span>
        {#if cooldown > 0}
          <SvBadge variant="neutral">Resend in {cooldown}s</SvBadge>
        {:else}
          <button class="link" onclick={resend}>Resend code</button>
        {/if}
      </div>
    {/if}

    {#snippet footer()}
      <div class="foot">
        <button class="link" onclick={() => toast('Back to sign-in')}>← Use a different account</button>
        <button class="link" onclick={() => toast('Recovery options opened')}>Trouble signing in?</button>
      </div>
    {/snippet}
  </SvCard>

  <p class="hint">Tip: enter <code>000000</code> to see the error state.</p>
</div>

<SvToaster position="bottom-right" />

<style>
  .otp-wrap { padding: 32px 24px; display: flex; flex-direction: column; align-items: center; gap: 12px; }
  /* Scoped to this demo's unique wrapper class so the global card-width rule
     can't leak onto other demos that also use a `.wrap` container. */
  .otp-wrap :global(> *:first-child) { width: 100%; max-width: 400px; }
  .head { text-align: center; margin-bottom: 18px; }
  .shield { font-size: 30px; display: block; margin-bottom: 8px; }
  .head h2 { margin: 0 0 4px; font-size: 21px; }
  .muted { color: var(--sg-muted, #64748b); font-size: 13.5px; margin: 0; }
  .otp { display: flex; justify-content: center; margin: 6px 0 18px; }
  .resend { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 14px; font-size: 13px; }
  .link { background: none; border: 0; padding: 0; font: inherit; color: var(--sg-accent, #4f46e5); cursor: pointer; }
  .link:hover { text-decoration: underline; }
  .foot { display: flex; flex-direction: column; gap: 6px; align-items: center; font-size: 12.5px; }
  .done { display: flex; align-items: center; gap: 14px; justify-content: center; padding: 18px 0; }
  .check { display: inline-flex; width: 40px; height: 40px; align-items: center; justify-content: center; border-radius: 50%; background: color-mix(in srgb, #22c55e 20%, transparent); color: #22c55e; font-size: 20px; font-weight: 700; }
  .done strong { font-size: 15px; }
  .hint { font-size: 12px; color: var(--sg-muted, #94a3b8); margin: 0; }
  .hint code { background: var(--sg-row-hover-bg, #f1f5f9); padding: 1px 5px; border-radius: 4px; }
</style>

View this example on GitHub

More Blocks examples

  • Login block - A two-column authentication page: a branded gradient panel beside a sign-in SvCard with social SvButtons, SvTextInput + SvPasswordInput (reveal), remember-me SvCheckBox, an "or" SvDivider and forgot-password link. Drops into any route as-is.
  • Sign-up block - A centered account-creation card: two-up name fields, work email, a password with the live strength meter, a plan SvSegmented switch, terms SvCheckBox and a benefits rail. Submit fires a promise toast. Pure UI-kit.
  • Analytics dashboard block - A full overview page: a range SvSegmented + actions header, a KPI grid (SvStat + SvSparkline), a revenue bar panel, a recent-activity SvTimeline and a top-products list with SvProgress bars. No grid dependency.
  • App shell block - A complete application layout: a SvNavPane sidebar with sections + badges, a top bar with SvBreadcrumb, search and an account SvMenu, and a routed content area. Nav swaps the page; the toggle collapses the rail to an icon rail.
  • Pricing block - A three-tier plan grid with a monthly/annual SvSegmented toggle (annual applies the discount live), a highlighted "most popular" card, per-plan feature SvLists with check marks, and a CTA SvButton on each.