Styleframe Logo
Forms

OTP

A one-time-password / pin input — a row of single-character cells with light, dark, and neutral surface colors, default, soft, and ghost styles, three sizes, and invalid, disabled, and focus states through the recipe system.

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 whose size axis 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-visible states.

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.

Styling, not behavior. These recipes style the cells. Auto-advancing focus, handling paste, and masking are interaction concerns you wire up yourself (or with a headless library) — the recipe stays framework-agnostic.

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:

src/components/otp.styleframe.ts
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":

src/components/Otp.tsx
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

ColorTokenUse Case
light@color.white / @color.gray-200Light surfaces, stays light in dark mode
dark@color.gray-900 / @color.gray-700Dark surfaces, stays dark in light mode
neutralAdaptive (light ↔ dark)Default color, adapts to the current color scheme
Pro tip: Use 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

SizeCell SizeFont SizeBorder Radius
sm40px@font-size.sm@border-radius.sm
md48px@font-size.md@border-radius.md
lg56px@font-size.lg@border-radius.md
Good to know: Pass the same 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.

PartRecipeRole
ContaineruseOtpRecipe()The .otp wrapper — owns the horizontal row layout and scales the gap between cells with size
CelluseOtpCellRecipe()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 an aria-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 and autocomplete="one-time-code" so mobile keyboards and OS-level SMS autofill work.
  • Don't rely on color alone. The invalid state 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 disabled on the input. Set the real disabled attribute in addition to the recipe state so the cells leave the tab order.
  • Verify contrast. The :focus-visible ring 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:

src/components/otp.styleframe.ts
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:

src/components/otp.styleframe.ts
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;
Good to know: Filtering also removes compound variants and adjusts default variants that reference filtered-out values, so your recipe stays consistent.

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:

ParameterTypeDescription
sStyleframeThe Styleframe instance
optionsDeepPartial<RecipeConfig>Optional overrides for the recipe configuration
options.baseVariantDeclarationsBlockCustom base styles for the container
options.variantsVariantsCustom variant definitions for the recipe
options.defaultVariantsRecord<keyof Variants, string>Default variant values for the recipe
options.filterRecord<string, string[]>Limit which variant values are generated

Variants:

VariantOptionsDefault
sizesm, md, lgmd

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:

VariantOptionsDefault
colorlight, dark, neutralneutral
variantdefault, soft, ghostdefault
sizesm, md, lgmd
invalidtrue, falsefalse
disabledtrue, falsefalse

Learn more about recipes →

Best Practices

  • Pass size to both parts: Spread the same size to the container and the cells so the gap scales with the cell size.
  • Use neutral for 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 an aria-label so 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 filter option to reduce generated CSS.

FAQ