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/sdkReact (>= 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.
| Import | What |
|---|---|
@k-otp/sdk/ui | The 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/react | React 18 and 19 components (a "use client" module) |
@k-otp/sdk/ui/vue | Vue 3.3+ components |
@k-otp/sdk/ui/svelte | Svelte 4 and 5 components |
@k-otp/sdk/ui/theme.css | The 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.
| Setting | Default | Description |
|---|---|---|
purpose | required | The issue purpose label. |
issue | Other issue fields: templateId, templateVariables, metadata, and so on. | |
codeLength | 6 | Digits per code. |
resendCooldownMs | 30000 | Local 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. |
autoSubmit | true | Verify as soon as every digit is entered. |
webOtp | true | Ask the browser to fill the code from an SMS after a send (see below). |
clearCodeOnMismatch | true | Clear the code after a wrong one. |
allowInternational | false | Accept non-Korean E.164 numbers. |
defaultPhoneNumber | Initial phone input. | |
locale | "ko" | "ko", "en", or "auto". |
messages | Override any message with a template or a function. | |
id | Root 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.
| Part | Renders | data-k-otp value |
|---|---|---|
OtpForm.Root | <form>, owns the flow | root |
OtpForm.PhoneField | label, <input type="tel">, description, and error | phone-field, phone-label, phone-input, and so on |
OtpForm.SendButton | send, "resend in 0:27", or "try again" | send-button |
OtpForm.CodeField | label, segmented input, description, and error | code-field, code-input, code-segment, and so on |
OtpForm.VerifyButton | verify, or "try again in 0:05" | verify-button |
OtpForm.Countdown | code expiry (role="timer", aria-live="off") | countdown |
OtpForm.Message | status or error (role="status", polite live region) | message |
OtpForm.EditPhoneButton | back to the phone step | edit-phone |
OtpCodeInput | a standalone segmented code input | code-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-stateis the phase:phone,sending,code,verifying,verified, orfailed(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, orcooldown. On code segments it isfilledorempty, on the countdownrunning,expired, oridle, and on the messageinfo,success,error, oridle. data-invalidmarks invalid fields (witharia-invalid="true"), anddata-disabledmarks locked inputs and buttons that do nothing right now (buttons also getaria-disabled="true").
Accessibility
- Labels are real
<label for>elements, and descriptions and errors are linked witharia-describedby. - The code input is a
role="group"where each segment is named "Digit 1 of 6". The first segment hasautocomplete="one-time-code"for iOS and Android SMS autofill, and all of them haveinputmode="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-disabledinstead ofdisabledwhile 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 #123456The 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.skippedwithreason: "channel_alimtalk": AlimTalk delivery with no SMS fallback (turned off withsmsFallback: 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.skippedwithreason: "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.