K-OTP

UI components

Ready-made phone verification forms for React, Vue, and Svelte, headless first with an optional theme. Part of @k-otp/sdk. Install it with `npm i @k-otp/sdk`.

The UI components are part of the @k-otp/sdk package (SDKs).

npm i @k-otp/sdk

React (>= 18), Vue (>= 3.3), and Svelte (>= 4) are optional peer dependencies, so install nothing extra: import the subpath of the framework your app already uses (@k-otp/sdk/ui/react, /ui/vue, or /ui/svelte). The default theme is an optional stylesheet, @k-otp/sdk/ui/theme.css.

A complete, accessible phone verification form for React, Vue, and Svelte: phone input, send and resend with a cooldown, a segmented code input with SMS autofill, verify, an expiry countdown, and status messages in Korean or English.

The components are headless first: every part renders plain, unstyled elements and exposes its state as data-* attributes, with render props or slots for full control. A default theme is an optional stylesheet.

ImportWhat
@k-otp/sdk/uiThe framework-agnostic model: phone parsing, the code input model, the form state machine (createOtpForm), the Korean and English messages, countdown formatting, and WebOTP
@k-otp/sdk/ui/reactReact 18 and 19 components (a "use client" module)
@k-otp/sdk/ui/vueVue 3.3+ components
@k-otp/sdk/ui/svelteSvelte 4 and 5 components
@k-otp/sdk/ui/theme.cssThe optional default theme

The hooks imports (@k-otp/sdk/react, /vue, /svelte) never load UI code, and each UI import only loads its own framework.

One line

import { OtpForm } from "@k-otp/sdk/ui/react";
import "@k-otp/sdk/ui/theme.css"; // optional

<OtpForm
	options={{ apiKey: "pk_..." }} // or inside <OtpProvider>, or client={...}
	purpose="signup"
	onVerified={(result) => console.log(result.issueId)}
/>;
<script setup lang="ts">
import { OtpForm } from "@k-otp/sdk/ui/vue";
import "@k-otp/sdk/ui/theme.css";
</script>

<!-- with app.use(createOtpPlugin({ apiKey: "pk_..." })), or pass client / options -->
<template>
	<OtpForm purpose="signup" @verified="(result) => console.log(result.issueId)" />
</template>
<script>
	import { OtpForm } from "@k-otp/sdk/ui/svelte";
	import "@k-otp/sdk/ui/theme.css";
</script>

<OtpForm options={{ apiKey: "pk_..." }} purpose="signup" onVerified={(r) => console.log(r.issueId)} />

A pk_ key only works from the origins listed exactly in its allowedOrigins (see Authentication). Verifying in the browser only tells the browser that the code matched. If your backend grants access, confirm on the server with getStatus and an sk_ key.

Korean mobile numbers are canonicalized before sending: 010-1234-5678, +82 10-1234-5678, and 0082 10 1234 5678 are all sent as 01012345678. Landlines are rejected, and other countries are accepted in E.164 form only with allowInternational.

Settings

Every form takes the same settings.

SettingDefaultDescription
purposerequiredThe issue purpose label.
issueOther issue fields: templateId, templateVariables, metadata, and so on.
codeLength6Digits per code.
resendCooldownMs30000Local cooldown after a send, for the number it was sent to. A UX guard, not a limit: the API's rate limits are the enforcement.
autoSubmittrueVerify as soon as every digit is entered.
webOtptrueAsk the browser to fill the code from an SMS after a send (see below).
clearCodeOnMismatchtrueClear the code after a wrong one.
allowInternationalfalseAccept non-Korean E.164 numbers.
defaultPhoneNumberInitial phone input.
locale"ko""ko", "en", or "auto".
messagesOverride any message with a template or a function.
idRoot id; part ids derive from it.

Events: sent, verified, error, and phaseChange. React and Svelte take onSent, onVerified, onError, and onPhaseChange props; Vue emits sent, verified, error, and phase-change.

A request that is still in flight when the form is unmounted, reset, or sent back to the phone step, or that a newer send replaces, is dropped silently: no error, sent, or verified for it. The next send of the same number reuses its idempotency key, so no second message goes out if the first one had reached the server.

Headless composition

The preset is built from parts you can arrange and style yourself.

PartRendersdata-k-otp value
OtpForm.Root<form>, owns the flowroot
OtpForm.PhoneFieldlabel, <input type="tel">, description, and errorphone-field, phone-label, phone-input, and so on
OtpForm.SendButtonsend, "resend in 0:27", or "try again"send-button
OtpForm.CodeFieldlabel, segmented input, description, and errorcode-field, code-input, code-segment, and so on
OtpForm.VerifyButtonverify, or "try again in 0:05"verify-button
OtpForm.Countdowncode expiry (role="timer", aria-live="off")countdown
OtpForm.Messagestatus or error (role="status", polite live region)message
OtpForm.EditPhoneButtonback to the phone stepedit-phone
OtpCodeInputa standalone segmented code inputcode-input, code-segment

Svelte has no namespaces, so the parts are OtpFormRoot, OtpFormPhoneField, and so on. In React, @k-otp/sdk/ui/react is a "use client" module: use the dot-notation parts and function props inside a client component. A Server Component can render the named exports (OtpFormRoot, OtpFormPhoneField, ...) with serializable props only.

<OtpForm.Root purpose="login" className="my-form" onVerified={done}>
	{({ state }) => (
		<>
			<OtpForm.PhoneField>
				{({ labelProps, inputProps, errorProps, parts }) => (
					<>
						<label {...labelProps}>Mobile</label>
						<input {...inputProps} className="my-input" />
						{parts.text.phoneError && <p {...errorProps}>{parts.text.phoneError}</p>}
					</>
				)}
			</OtpForm.PhoneField>
			{state.issued && <OtpForm.CodeField />}
			{state.issued && <OtpForm.VerifyButton />}
			<OtpForm.SendButton />
			<OtpForm.Message />
		</>
	)}
</OtpForm.Root>

Vue uses scoped slots with the same values, and Svelte uses let: slot props (or snippets in Svelte 5).

State attributes

Every part carries data-k-otp="<part>" and a data-state:

  • On the root, fields, inputs, and edit-phone, data-state is the phase: phone, sending, code, verifying, verified, or failed (the current code can no longer be used, for example expired or too many attempts; sending again starts over).
  • On the send and verify buttons it is idle, loading, or cooldown. On code segments it is filled or empty, on the countdown running, expired, or idle, and on the message info, success, error, or idle.
  • data-invalid marks invalid fields (with aria-invalid="true"), and data-disabled marks locked inputs and buttons that do nothing right now (buttons also get aria-disabled="true").

Accessibility

  • Labels are real <label for> elements, and descriptions and errors are linked with aria-describedby.
  • The code input is a role="group" where each segment is named "Digit 1 of 6". The first segment has autocomplete="one-time-code" for iOS and Android SMS autofill, and all of them have inputmode="numeric".
  • The code input is one tab stop: Tab and Shift+Tab leave it from any segment, and the arrow keys, Home, and End move between segments. Paste fills across segments, and digits composed with an input method (IME) are applied once the composition ends.
  • Focus follows the flow: to the first code segment after a send and after a wrong code, to the result message after verification, and back to the phone input after "change number".
  • Buttons use aria-disabled instead of disabled while a request or cooldown is running, so they keep focus. The countdown is not announced every second.

Theme

@k-otp/sdk/ui/theme.css is optional. It styles only [data-k-otp] elements, with zero-specificity selectors, so any class of yours wins. Light and dark follow prefers-color-scheme; force one with data-k-otp-theme="light" or "dark" on the form or any ancestor. Tune it with custom properties:

:root {
	--k-otp-accent: #0f766e;
	--k-otp-radius: 0.5rem;
	--k-otp-font: inherit;
}

Other tokens include --k-otp-surface, --k-otp-text, --k-otp-muted, --k-otp-danger, --k-otp-success, --k-otp-focus-ring, --k-otp-max-width, and --k-otp-segment-size. The defaults keep field borders and the focus outline at 3:1 or more and text at 4.5:1 or more in light and dark, so keep that in mind when overriding them. Forced-colors mode uses system colors, and motion is removed under prefers-reduced-motion: reduce. Without the stylesheet the parts are unstyled.

Locale and messages

locale is "ko" by default, on the server and in the browser, or "en". "auto" follows the page's <html lang>: Korean for ko* or no lang, English for any other language. It renders Korean on the server and on the first client render, so hydration always matches, then switches right after mount and follows later lang changes.

messages overrides any message key with a template ({name} placeholders) or a function:

<OtpForm
	purpose="signup"
	locale="en"
	messages={{
		"send.idle": "Text me a code",
		"reason.MISMATCH": ({ attempts }) => `Wrong code (${attempts} left)`,
	}}
/>

Every SDK error code, verify reason, and skipped action has a message. An ambiguous send failure (timeout, network error, 5xx) says that trying again is safe, because the same idempotency key is reused.

WebOTP (SMS autofill on Android Chrome)

After a send, the form calls navigator.credentials.get({ otp }) when the browser supports it. It is feature-detected and aborted on verify, "change number", and unmount. Chrome fills the code after a one-tap consent only if the SMS ends with an origin-bound line:

[K-OTP] 인증번호 123456

@www.example.com #123456

The last line must be @<host of the page> #<code>. K-OTP adds it to the SMS only when the issue asks for it: set the form's issue setting to { webOtp: true } (the webOtp field of POST /v1/issue) with a pk_ key whose allowedOrigins lists the page's origin (https, no path or port). Without it the SMS has no such line and the browser request simply never resolves, so leaving the form's webOtp setting on is harmless. iOS and macOS Safari and Android keyboards offer the code through autocomplete="one-time-code" either way. Turn it off with webOtp={false}.

Browsers cannot read KakaoTalk messages, so WebOTP only fills a code that arrives by SMS. The issue response reports the outcome in webOtp.status:

  • fallback_only: AlimTalk delivery (the default). Only the automatic SMS fallback carries the line, so autofill works only when AlimTalk fails and the SMS is sent.
  • skipped with reason: "channel_alimtalk": AlimTalk delivery with no SMS fallback (turned off with smsFallback: false, API 1.8.0, or unavailable). No SMS is sent, so WebOTP never fills the code.
  • applied: SMS delivery (channel: "sms" or an SMS-only template); the SMS carries the line.
  • skipped with reason: "host_too_long": the line did not fit in one SMS, so the normal text was sent.

See Channels and SMS fallback.

Server-side rendering

Importing any UI import and rendering the components on the server performs no request, starts no timer, and never touches window. When you server-render, pass an id (React and Vue 3.5 generate matching ids, but Svelte and older Vue generate a random one that would differ on the client) and keep locale the same on both sides.

The UI components only use a pk_ key. The sk_ client is @k-otp/sdk/server, which becomes a throwing stub in browser bundles (see SDKs).

Svelte 4 and 5

@k-otp/sdk/ui/svelte ships the components as .svelte sources (through the svelte export condition) over a compiled runtime, written in syntax that both Svelte 4 and 5 accept. Your own Svelte compiles them, so they always match your runtime. With Vite, use @sveltejs/vite-plugin-svelte or SvelteKit. If you force compilerOptions.runes: true globally, exclude node_modules, as for any library written in Svelte 4 syntax.

On this page