All components
Pin Input
One-time code entry with per-digit boxes in outline, subtle, and flushed variants.
1234567891011121314import { PinInput } from "#components/ui/base-ui/pin-input.tsx";import { accentPalette } from "@stylexblocks/ui/themes/palettes.ts";
export function Example() { // Base UI ships no pin-input machine — the component composes the recipe // with useRender/mergeProps around per-digit inputs with focus management return ( <PinInput length={4} defaultValue={["3", "1", "", ""]} variant="outline" size="md" palette={accentPalette} aria-label="One-time code" /> );}
// controlled: <PinInput value={digits} onValueChange={setDigits} />// composition: <PinInput render={<fieldset />} />Variants
outline
subtle
flushed
Sizes
xs
sm
md
lg
xl
Install
1npx stylexblocks add pin-inputSource
components/ui/base-ui/pin-input.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243import * as stylex from "@stylexjs/stylex";import { useRef, useState } from "react";import { useRender } from "@base-ui-components/react/use-render";import { createSlotRecipeContext } from "#lib/base-ui/create-slot-recipe-context.tsx";import { defineSlotRecipe, type StyleOverrideProps,} from "#lib/recipe-props.ts";import { accentPalette } from "@stylexblocks/ui/themes/palettes.ts";import { colorPalette } from '@stylexblocks/ui/color-palette.stylex.ts'import { fontSizes } from '@stylexblocks/ui/tokens/font-sizes.stylex.ts'import { lineHeights } from '@stylexblocks/ui/tokens/line-heights.stylex.ts'import { semanticColors } from '@stylexblocks/ui/semantic-tokens/colors.stylex.ts'import { semanticRadii } from '@stylexblocks/ui/semantic-tokens/radii.stylex.ts'import { sizes } from '@stylexblocks/ui/tokens/sizes.stylex.ts'
const pinInputInput = stylex.create({ base: { width: 'var(--input-height)', minWidth: 'var(--input-height)', outline: 'none', position: 'relative', appearance: 'none', textAlign: 'center', opacity: { default: null, ':is(:disabled, [disabled], [data-disabled])': 0.5, }, cursor: { default: null, ':is(:disabled, [disabled], [data-disabled])': 'not-allowed', }, height: 'var(--input-height)', '--focus-color': colorPalette.focusRing, '--error-color': semanticColors.borderError, '--focus-ring-color': { default: null, ':is([data-invalid], [aria-invalid="true"])': 'var(--error-color)', }, borderColor: { default: null, ':is([data-invalid], [aria-invalid="true"])': 'var(--error-color)', }, },})
const pinInputInputSizes = stylex.create({ xs: { borderRadius: semanticRadii.control, fontSize: fontSizes.xs, lineHeight: lineHeights.xs, paddingInline: 0, '--input-height': sizes.controlXs, }, sm: { borderRadius: semanticRadii.control, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, paddingInline: 0, '--input-height': sizes.controlSm, }, md: { borderRadius: semanticRadii.control, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, paddingInline: 0, '--input-height': sizes.controlMd, }, lg: { borderRadius: semanticRadii.control, fontSize: fontSizes.md, lineHeight: lineHeights.md, paddingInline: 0, '--input-height': sizes.controlLg, }, xl: { borderRadius: semanticRadii.control, fontSize: fontSizes.md, lineHeight: lineHeights.md, paddingInline: 0, '--input-height': sizes.controlXl, },})
const pinInputInputVariants = stylex.create({ outline: { backgroundColor: semanticColors.bg, borderWidth: '1px', borderColor: { default: semanticColors.border, ':is([data-invalid], [aria-invalid="true"])': 'var(--error-color)', ':hover': semanticColors.borderEmphasized, }, outlineWidth: { default: null, ':focus-visible': 'var(--focus-ring-width, 0)', }, outlineOffset: { default: null, ':focus-visible': '0px', }, outlineStyle: { default: null, ':focus-visible': 'solid', }, outlineColor: { default: null, ':focus-visible': colorPalette.focusRing, }, focusRingWidth: 0, }, subtle: { borderWidth: '1px', borderColor: { default: 'transparent', ':is([data-invalid], [aria-invalid="true"])': 'var(--error-color)', }, backgroundColor: semanticColors.bgMuted, outlineWidth: { default: null, ':focus-visible': 'var(--focus-ring-width, 0)', }, outlineOffset: { default: null, ':focus-visible': '0px', }, outlineStyle: { default: null, ':focus-visible': 'solid', }, outlineColor: { default: null, ':focus-visible': colorPalette.focusRing, }, }, flushed: { backgroundColor: 'transparent', borderBottomWidth: '1px', borderBottomColor: semanticColors.border, borderRadius: 0, paddingInline: 0, borderColor: { default: null, ':is([data-invalid], [aria-invalid="true"])': 'var(--error-color)', ':focus-visible': 'var(--focus-color)', }, boxShadow: { default: null, ':focus-visible': '0px 1px 0px 0px var(--focus-color)', }, },})
export type PinInputSize = keyof typeof pinInputInputSizes
export type PinInputVariant = keyof typeof pinInputInputVariants
const pinInputRecipe = defineSlotRecipe({ slots: { input: { styles: pinInputInput, sizes: pinInputInputSizes, variants: pinInputInputVariants, }, }, defaultVariants: { size: "md", variant: "outline" },});
const chrome = stylex.create({ control: { display: "inline-flex", gap: "0.5rem" },});
const { withProvider, withContext } = createSlotRecipeContext(pinInputRecipe);
const StyledRoot = withProvider("div", () => [chrome.control], "PinInputRoot");const Digit = withContext("input", "input", "PinInputInput");
export interface PinInputProps extends Omit<useRender.ComponentProps<"div">, "style" | "defaultValue" | "onChange">, StyleOverrideProps { variant?: PinInputVariant; size?: PinInputSize; /** Number of digit boxes. */ length?: number; value?: string[]; defaultValue?: string[]; onValueChange?: (value: string[]) => void;}
/** * Pin Input — the StyleX slot recipe composed with Base UI's * useRender/mergeProps utilities: per-digit inputs with focus advancing to * the next box as digits are entered. `<PinInput variant size>` provides * the recipe once; each digit inherits it through context. */export function PinInput({ length = 4, value, defaultValue, onValueChange, palette = accentPalette, ...rest}: PinInputProps) { const [inner, setInner] = useState<string[]>( () => defaultValue ?? Array.from({ length }, () => ""), ); const digits = value ?? inner; const inputs = useRef<(HTMLInputElement | null)[]>([]);
function update(index: number, digit: string) { const next = digits.map((d, i) => (i === index ? digit : d)); setInner(next); onValueChange?.(next); if (digit && index < length - 1) inputs.current[index + 1]?.focus(); }
return ( <StyledRoot role="group" palette={palette} {...rest}> {Array.from({ length }, (_, index) => ( <Digit key={index} ref={(node: HTMLInputElement | null) => { inputs.current[index] = node; }} inputMode="numeric" autoComplete="one-time-code" maxLength={1} aria-label={`Digit ${index + 1}`} value={digits[index] ?? ""} onChange={(event: React.ChangeEvent<HTMLInputElement>) => update(index, event.target.value.slice(-1)) } onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => { if (event.key === "Backspace" && !digits[index] && index > 0) { inputs.current[index - 1]?.focus(); } }} /> ))} </StyledRoot> );}