An accessible and customizable OTP Input component.
import { OTPField, OTPFieldGroup, OTPFieldInput, OTPFieldSeparator, OTPFieldSlot, } from "@repo/tailwindcss/ui/otp-field"; const OtpFieldDemo = () => { return ( <OTPField maxLength={6}> <OTPFieldInput /> <OTPFieldGroup> <OTPFieldSlot index={0} /> <OTPFieldSlot index={1} /> <OTPFieldSlot index={2} /> </OTPFieldGroup> <OTPFieldSeparator /> <OTPFieldGroup> <OTPFieldSlot index={3} /> <OTPFieldSlot index={4} /> <OTPFieldSlot index={5} /> </OTPFieldGroup> </OTPField> ); }; export default OtpFieldDemo;
npx shadcn-solid@latest add otp-field
npm install @corvu/otp-field
import { cn } from "@/libs/cn"; import type { DynamicProps, RootProps } from "@corvu/otp-field"; import OTPFieldPrimitive from "@corvu/otp-field"; import type { ComponentProps, ValidComponent } from "solid-js"; import { Show, splitProps } from "solid-js"; export const OTPFieldInput = OTPFieldPrimitive.Input; type OTPFieldProps<T extends ValidComponent = "div"> = RootProps<T> & { class?: string; }; export const OTPField = <T extends ValidComponent = "div">( props: DynamicProps<T, OTPFieldProps<T>>, ) => { const [local, rest] = splitProps(props, ["class"]); return ( <OTPFieldPrimitive class={cn( "flex items-center gap-2 has-[:disabled]:opacity-50", local.class, )} {...rest} /> ); }; export const OTPFieldGroup = (props: ComponentProps<"div">) => { const [local, rest] = splitProps(props, ["class"]); return <div class={cn("flex items-center", local.class)} {...rest} />; }; export const OTPFieldSeparator = (props: ComponentProps<"div">) => { return ( // biome-ignore lint/a11y/useAriaPropsForRole: [] <div role="separator" {...props}> <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 15 15" > <title>Separator</title> <path fill="currentColor" fill-rule="evenodd" d="M5 7.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5" clip-rule="evenodd" /> </svg> </div> ); }; export const OTPFieldSlot = ( props: ComponentProps<"div"> & { index: number }, ) => { const [local, rest] = splitProps(props, ["class", "index"]); const context = OTPFieldPrimitive.useContext(); const char = () => context.value()[local.index]; const hasFakeCaret = () => context.value().length === local.index && context.isInserting(); const isActive = () => context.activeSlots().includes(local.index); return ( <div class={cn( "relative flex size-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-shadow first:rounded-l-md first:border-l last:rounded-r-md", isActive() && "z-10 ring-[1.5px] ring-ring", local.class, )} {...rest} > {char()} <Show when={hasFakeCaret()}> <div class="pointer-events-none absolute inset-0 flex items-center justify-center"> <div class="h-4 w-px animate-caret-blink bg-foreground" /> </div> </Show> </div> ); };
/** @type {import('tailwindcss').Config} */ module.exports = { theme: { extend: { keyframes: { "accordion-down": { from: { height: 0 }, to: { height: "var(--kb-accordion-content-height)" } }, "accordion-up": { from: { height: "var(--kb-accordion-content-height)" }, to: { height: 0 } } }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out" } } } };
import { cn } from "@/libs/cn"; import type { DynamicProps, RootProps } from "@corvu/otp-field"; import OTPFieldPrimitive from "@corvu/otp-field"; import type { ComponentProps, ValidComponent } from "solid-js"; import { Show, splitProps } from "solid-js"; export const OTPFieldInput = OTPFieldPrimitive.Input; type OTPFieldProps<T extends ValidComponent = "div"> = RootProps<T> & { class?: string; }; export const OTPField = <T extends ValidComponent = "div">( props: DynamicProps<T, OTPFieldProps<T>>, ) => { const [local, rest] = splitProps(props, ["class"]); return ( <OTPFieldPrimitive class={cn( "flex items-center gap-2 has-[:disabled]:opacity-50", local.class, )} {...rest} /> ); }; export const OTPFieldGroup = (props: ComponentProps<"div">) => { const [local, rest] = splitProps(props, ["class"]); return <div class={cn("flex items-center", local.class)} {...rest} />; }; export const OTPFieldSeparator = (props: ComponentProps<"div">) => { return ( // biome-ignore lint/a11y/useAriaPropsForRole: [] <div role="separator" {...props}> <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 15 15" > <title>Separator</title> <path fill="currentColor" fill-rule="evenodd" d="M5 7.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5" clip-rule="evenodd" /> </svg> </div> ); }; export const OTPFieldSlot = ( props: ComponentProps<"div"> & { index: number }, ) => { const [local, rest] = splitProps(props, ["class", "index"]); const context = OTPFieldPrimitive.useContext(); const char = () => context.value()[local.index]; const hasFakeCaret = () => context.value().length === local.index && context.isInserting(); const isActive = () => context.activeSlots().includes(local.index); return ( <div class={cn( "relative flex size-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:(rounded-l-md border-l) last:rounded-r-md", isActive() && "z-10 ring-1.5 ring-ring", local.class, )} {...rest} > {char()} <Show when={hasFakeCaret()}> <div class="pointer-events-none absolute inset-0 flex items-center justify-center"> <div class="h-4 w-px animate-caret-blink bg-foreground" /> </div> </Show> </div> ); };
export default defineConfig({ themes: { animation: { keyframes: { "caret-blink": "{ 0%,70%,100% { opacity: 1 } 20%,50% { opacity: 0 } }" }, timingFns: { "caret-blink": "ease-out" }, durations: { "caret-blink": "1.25s" }, counts: { "caret-blink": "infinite" } } } });
import { OTPField, OTPFieldGroup, OTPFieldInput, OTPFieldSeparator, OTPFieldSlot } from "@/components/ui/otp-field";
<OTPField maxLength={6}> <OTPFieldInput /> <OTPFieldGroup> <OTPFieldSlot index={0} /> <OTPFieldSlot index={1} /> <OTPFieldSlot index={2} /> </OTPFieldGroup> <OTPFieldSeparator /> <OTPFieldGroup> <OTPFieldSlot index={3} /> <OTPFieldSlot index={4} /> <OTPFieldSlot index={5} /> </OTPFieldGroup> </OTPField>
Use the pattern prop to define a custom pattern for the OTP field.
pattern
import { OTPField, OTPFieldGroup, OTPFieldInput, OTPFieldSlot, } from "@repo/tailwindcss/ui/otp-field"; const OTPFieldWithPatternDemo = () => { return ( <OTPField maxLength={6}> <OTPFieldInput pattern="^[a-zA-Z0-9]*$" /> <OTPFieldGroup> <OTPFieldSlot index={0} /> <OTPFieldSlot index={1} /> <OTPFieldSlot index={2} /> <OTPFieldSlot index={3} /> <OTPFieldSlot index={4} /> <OTPFieldSlot index={5} /> </OTPFieldGroup> </OTPField> ); }; export default OTPFieldWithPatternDemo;
You can use the value and onValueChange props to control the input value.
value
onValueChange
import { OTPField, OTPFieldGroup, OTPFieldInput, OTPFieldSlot, } from "@repo/tailwindcss/ui/otp-field"; import { Show, createSignal } from "solid-js"; const OtpFieldWithControlledDemo = () => { const [value, setValue] = createSignal<string>(); return ( <div class="flex flex-col items-center gap-2"> <OTPField maxLength={6} value={value()} onValueChange={setValue}> <OTPFieldInput /> <OTPFieldGroup> <OTPFieldSlot index={0} /> <OTPFieldSlot index={1} /> <OTPFieldSlot index={2} /> <OTPFieldSlot index={3} /> <OTPFieldSlot index={4} /> <OTPFieldSlot index={5} /> </OTPFieldGroup> </OTPField> <span class="text-center text-sm"> <Show fallback="Enter your one-time password." when={value()}> You entered: {value()} </Show> </span> </div> ); }; export default OtpFieldWithControlledDemo;