OTP
Overview
The OTP input (also called a pin input) is a row of single-character fields for entering a short code — a one-time password, a 2FA token, or a verification PIN. It is composed of two recipe parts:
useOtpRecipe()— the row container that owns the layout: a horizontal flex row whosesizeaxis scales the gap between cells.useOtpCellRecipe()— the single-character cell that sits directly on each native<input>. It owns the surface (color, style, size), centered text, and the invalid, disabled, and:focus-visiblestates.
Each cell is the real native <input>, so focus and the focus ring are driven by the browser: :focus-visible shows a ring in @color.primary, and the invalid state switches that ring and the border to @color.error. The cell shares the exact color / style / state surface as the Input recipe, so an OTP field feels like the rest of your forms.
The OTP recipes integrate directly with the default design tokens preset and generate type-safe utility classes at build time with zero runtime CSS.
Why use the OTP recipe?
The OTP recipe helps you:
- Ship faster with sensible defaults: Get 3 surface colors, 3 styles, and 3 sizes out of the box with a single set of composable calls.
- Match the rest of your forms: The cell reuses the Input recipe's color / style / state surface, so an OTP field shares the same visual language as your text inputs.
- Maintain consistency: The focus ring, invalid border, and dark-mode surfaces follow the same design rules everywhere.
- Customize without forking: Override base styles, default variants, or filter out options you don't need — all through the options API.
- Stay type-safe: Full TypeScript support means your editor catches invalid color, style, or size values at compile time.
- Integrate with your tokens: Every value references the design tokens preset, so theme changes propagate automatically.
Usage
Register the recipes
Add the OTP recipes to a local Styleframe instance. The global styleframe.config.ts provides design tokens and utilities, while the component-level file registers the recipes themselves:
import { styleframe } from 'virtual:styleframe';
import { useOtpRecipe, useOtpCellRecipe } from '@styleframe/theme';
const s = styleframe();
const otp = useOtpRecipe(s);
const otpCell = useOtpCellRecipe(s);
export default s;
Build the component
Put the otp container on the wrapper element and the otpCell class on each native <input>. Render one input per character, capped at a single character with maxlength="1":
import { otp, otpCell } from "virtual:styleframe";
interface OtpProps {
color?: "light" | "dark" | "neutral";
variant?: "default" | "soft" | "ghost";
size?: "sm" | "md" | "lg";
length?: number;
invalid?: boolean;
disabled?: boolean;
}
export function Otp({
color = "neutral",
variant = "default",
size = "md",
length = 6,
invalid = false,
disabled = false,
}: OtpProps) {
return (
<div className={otp({ size })} role="group" aria-label="One-time password">
{Array.from({ length }).map((_, i) => (
<input
key={i}
className={otpCell({
color,
variant,
size,
invalid: invalid ? "true" : "false",
disabled: disabled ? "true" : "false",
})}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={1}
disabled={disabled}
/>
))}
</div>
);
}
See it in action
Colors
The OTP cell includes 3 color variants: light, dark, and neutral. Like the Input and Checkbox recipes, these are neutral-spectrum surface colors rather than status colors — the color sets the cell background and border, while the :focus-visible ring stays @color.primary.
The neutral color adapts automatically: a light surface in light mode and a dark surface in dark mode, making it the safest default for general-purpose forms.
Color Reference
| Color | Token | Use Case |
|---|---|---|
light | @color.white / @color.gray-200 | Light surfaces, stays light in dark mode |
dark | @color.gray-900 / @color.gray-700 | Dark surfaces, stays dark in light mode |
neutral | Adaptive (light ↔ dark) | Default color, adapts to the current color scheme |
neutral as your default OTP color. It adapts to the user's color scheme automatically, so you don't need to manage light and dark surfaces separately.Variants
Three visual styles control how much surface each cell shows. All three share the same :focus-visible ring and invalid border — they differ only in their resting background and border.
Default
default draws a bordered cell on a solid surface — the most legible option and the right default for verification flows.
Soft
soft fills the cell with a subtle tinted background and a matching border, for a gentler look that still reads as an input.
Ghost
ghost is transparent until focused, for dense or low-chrome layouts. Pair it with a clear label so the field stays discoverable.
Sizes
Three size variants from sm to lg control the square cell dimensions, font size, and border radius. Cells are square so digits stay centered both ways.
Size Reference
| Size | Cell Size | Font Size | Border Radius |
|---|---|---|---|
sm | 40px | @font-size.sm | @border-radius.sm |
md | 48px | @font-size.md | @border-radius.md |
lg | 56px | @font-size.lg | @border-radius.md |
size to the otp container and the otpCell cells so the gap between cells scales with the cell size.States
Invalid
Set the invalid state when the entered code is wrong or expired. The cell border and the :focus-visible ring switch to @color.error, layered over whatever color and style the cell already uses.
Disabled
The disabled state dims the cells to 0.5 opacity, switches the cursor to not-allowed, and blocks pointer interaction. Mirror it with the native disabled attribute so the inputs also leave the tab order.
Anatomy
The OTP input is composed of two independent recipes: a row container and the repeated cell.
| Part | Recipe | Role |
|---|---|---|
| Container | useOtpRecipe() | The .otp wrapper — owns the horizontal row layout and scales the gap between cells with size |
| Cell | useOtpCellRecipe() | The .otp-cell native <input> — owns the surface color, style, square size, centered text, and the invalid / disabled / focus states |
<div class="otp(...)" role="group" aria-label="One-time password">
<input class="otpCell(...)" type="text" maxlength="1" />
<!-- one input per character -->
</div>
The container carries no color or style axis; all surface styling lives on the cell, so every cell in a row stays visually identical.
Accessibility
- Group and label the cells. Wrap the inputs in an element with
role="group"and anaria-label(e.g. "One-time password") so assistive technology announces them as one control rather than several unrelated fields. - Keep native inputs. Styling real
<input>elements preserves keyboard operation, focus order, and form submission for free. - Hint the input type. Set
inputmode="numeric"for digit codes andautocomplete="one-time-code"so mobile keyboards and OS-level SMS autofill work. - Don't rely on color alone. The
invalidstate changes the border and ring color; pair it with a visible error message so the failure isn't conveyed by color only, satisfying WCAG 1.4.1. - Mirror
disabledon the input. Set the realdisabledattribute in addition to the recipe state so the cells leave the tab order. - Verify contrast. The
:focus-visiblering is@color.primary. Default tokens meet WCAG AA; if you override the primary color, verify with the WebAIM Contrast Checker.
Customization
Overriding Defaults
Each OTP composable accepts an optional second argument to override any part of the recipe configuration. Overrides are deep-merged with the defaults, so you only need to specify the properties you want to change:
import { styleframe } from 'virtual:styleframe';
import { useOtpCellRecipe } from '@styleframe/theme';
const s = styleframe();
const otpCell = useOtpCellRecipe(s, {
base: {
fontWeight: '@font-weight.semibold',
},
defaultVariants: {
color: 'neutral',
size: 'lg',
},
});
export default s;
Filtering Variants
If you only need a subset of the available variants, use the filter option to limit which values are generated. This reduces the output CSS and keeps your component API focused:
import { styleframe } from 'virtual:styleframe';
import { useOtpCellRecipe } from '@styleframe/theme';
const s = styleframe();
// Only generate the neutral color and the default style
const otpCell = useOtpCellRecipe(s, {
filter: {
color: ['neutral'],
variant: ['default'],
},
});
export default s;
API Reference
useOtpRecipe(s, options?)
Creates the OTP container recipe — the .otp row that lays out the cells and scales the gap between them with size.
Parameters:
| Parameter | Type | Description |
|---|---|---|
s | Styleframe | The Styleframe instance |
options | DeepPartial<RecipeConfig> | Optional overrides for the recipe configuration |
options.base | VariantDeclarationsBlock | Custom base styles for the container |
options.variants | Variants | Custom variant definitions for the recipe |
options.defaultVariants | Record<keyof Variants, string> | Default variant values for the recipe |
options.filter | Record<string, string[]> | Limit which variant values are generated |
Variants:
| Variant | Options | Default |
|---|---|---|
size | sm, md, lg | md |
useOtpCellRecipe(s, options?)
Creates the OTP cell recipe — the .otp-cell native <input> that owns the surface color, style, square size, centered text, and the invalid / disabled / focus states. Accepts the same parameters as useOtpRecipe.
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | default, soft, ghost | default |
size | sm, md, lg | md |
invalid | true, false | false |
disabled | true, false | false |
Best Practices
- Pass
sizeto both parts: Spread the samesizeto the container and the cells so the gap scales with the cell size. - Use
neutralfor general forms: The neutral color adapts to light and dark mode automatically, making it the safest default. - Cap each cell at one character: Set
maxlength="1"and wire up auto-advance so focus moves to the next cell as the user types. - Group and label the row: Use
role="group"and anaria-labelso the cells are announced as a single control. - Filter what you don't need: If your forms use only the neutral color and default style, pass a
filteroption to reduce generated CSS.
FAQ
color axis controls the cell surface, which reflects the form's surface rather than a status. This mirrors the Input recipe's color model. For an error state, use the invalid state, which switches the border and focus ring to @color.error.:focus-within ring, while each OTP cell is the focusable <input> and uses a :focus-visible ring.<input> per character — iterate over a length (e.g. v-for="i in length" in Vue or Array.from({ length }) in React) and apply the otpCell() class to each. The otp() container handles the row layout and spacing.filter option, compound variants that reference filtered-out colors or styles are automatically removed, and default variants are adjusted if they reference a removed value — so the recipe stays consistent and only emits the CSS you use.Input
A text-field component with a wrapper-owned visual surface, inline prefix/suffix addons, and invalid/disabled/readonly states. Supports light, dark, and neutral colors, default/soft/ghost styles, and three sizes through the recipe system.
Radio
A custom radio built on the native input, with a CSS dot indicator, checked, disabled, and focus states, light, dark, and neutral surface colors, and three sizes through the recipe system.