Expokit
AboutDocsComponentsChartsBlocksPricing
Sign inGet started
All components

Inputs

CalendarNewDate PickerNewFloating InputOTP InputPassword InputSearch BarSelectSliderStepperTag InputTextareaTime PickerNew

Selection

CheckboxChip GroupColor SwatchProRadio GroupRatingProSegmented ControlSwitch

Buttons

ButtonFloating Action ButtonProLike ButtonProMorph ButtonProShimmer ButtonProSlide to ConfirmProStateful Button

Feedback

AlertProgressSkeletonSpinnerToast

Overlays

Action SheetNewBottom SheetDialogDrawerNew

Display

AccordionAvatarBadgeCardCarouselProList ItemNewPagination DotsNewSwipeable RowProTimelineNew

Navigation

Bottom NavNewProgress StepsNewTabs

Motion

Number TickerProText Animation
OTP Input
Sponsor ExpoKit

Free stays free. Tip once — keep shipping.

Support
About
Docs
IntroductionWhat ExpoKit is and how it worksInstallationAdd ExpoKit to an Expo appThemingColours, radius and dark modeCLIinit, add, login and updateAI assistants (MCP)Cursor, Claude, Antigravity and moreRun on your phoneTry components in Expo GoChangelogWhat shipped recently
npx @expokit/cli@latest initQuick start
Components
Liquid Glass8 iOS 26 glass pieces3DNew14 tilt, flip and stack
Inputs 12
  • CalendarNew
  • Date PickerNew
  • Floating Input
  • OTP Input
  • Password Input
  • +7 more
Selection 7
  • Checkbox
  • Chip Group
  • Color SwatchPro
  • Radio Group
  • RatingPro
  • +2 more
Buttons 7
  • Button
  • Floating Action ButtonPro
  • Like ButtonPro
  • Morph ButtonPro
  • Shimmer ButtonPro
  • +2 more
Feedback 5
  • Alert
  • Progress
  • Skeleton
  • Spinner
  • Toast
Overlays 4
  • Action SheetNew
  • Bottom Sheet
  • Dialog
  • DrawerNew
Display 9
  • Accordion
  • Avatar
  • Badge
  • Card
  • CarouselPro
  • +4 more
Navigation 3
  • Bottom NavNew
  • Progress StepsNew
  • Tabs
Motion 2
  • Number TickerPro
  • Text Animation
70 components · 347 live variantsAll components
Charts

Cartesian

  • Area Chart
  • Bar Chart
  • Line Chart
  • Combo Chart

Circular

  • Pie & Donut
  • Radial Chart
  • Radar Chart
  • Polar AreaPro

3D & Pixel

  • Isometric BarsPro
  • Pixel BarsPro

Specialty

  • TreemapPro
  • WaterfallPro
  • BulletPro
  • TimelinePro
  • Gauge
  • Candlestick
  • Heatmap
  • Funnel

Classic

  • Sparkline
  • Progress Rings
  • MultiLine (classic)
  • StackedBar (classic)
  • Ranking Bars (classic)

Guides

  • Usage & config
  • Patterns & effects
  • Show / hide anything
  • Chart theming
  • Interactions
23 charts, built on the open-source expo-charts engineAll charts
Blocks

Screens

ChatProAn AI assistant chat screenCheckoutProA premium checkoutDashboardProA finance / analytics home screenInboxProA messaging inboxOnboardingProA minimal swipeable welcome flowProfileProAccount profile for any appSearch ResultsProA clean, monochrome search screenSettingsProAn iOS-style grouped settings screenSign InProOne configurable sign-in flow modelled on top mobile apps
9 complete screens — drop one in, wire your dataSee them on devices All blocks
Pricing
DocsComponentsInputsOTP Input

OTP Input

Scan with Expo Go

OTP Input with all its variants, native in Expo Go. No account needed.

No Expo Go? Open in your browser

Digits pop into cells that size themselves, with SMS autofill and paste.

<OtpInput />5 variantsiOS · Android · Expo Go
9:41

Sunday 18 October

9:41

Installation#

One command copies the source into your app — you own the code and can change anything.

Usage#

Colours, radius and light / dark come from useKit(). Wrap your app in <KitProvider accent="#6366f1" radius={14}> to re-skin every component at once.

Props#

PropTypeDefault
length

Number of cells.

number6
value / onChangeText

Controlled code (digits only). Pasting a longer string fills every cell.

string—
onComplete

Called when every cell is filled.

(code) => void—
variant

Rounded box cells, underline bars, or round pill cells.

"box" | "underline" | "pill""box"
error

Shakes the row and turns it the danger colour.

booleanfalse
errorText

Message (with icon) shown under the cells while `error` is set.

string—
success

Success colour with a staggered pulse across the cells.

booleanfalse
successText

Message with an animated check shown while `success` is set.

string—
helperText

Hint shown under the cells otherwise.

string—
separatorAfter

Draw a dash after this many cells, e.g. 3 → 123 – 456.

number—
maxCellSize

Largest cell width; cells shrink to fit the row and never overflow.

number52
secure

Dots instead of digits.

booleanfalse
disabled

Dims and blocks input.

booleanfalse
autoFocus

Focus on mount.

booleanfalse
accent

Active cell colour.

stringtheme accent
style

Style for the root view around the cells and message.

StyleProp<ViewStyle>—

Behaviour & accessibility#

Touch
Tapping the row focuses one hidden number-pad input. Pasting a code like 123-456 strips the non-digits and fills every cell.
Motion
Digits pop in on a bouncy spring and the active cell glows with a halo and a blinking caret. Errors shake the row; success pulses across the cells.
Platforms
Sets textContentType 'oneTimeCode' and autoComplete 'one-time-code', so codes from SMS can autofill.
Screen readers
The cells are hidden; the input is labelled 'One-time code, 6 digits' with a hint. Helper, error and success messages are a polite live region.
Web
The browser focus outline is removed; the border and halo show focus instead.

Source#

PreviousFloating InputNextPassword Input

Did you like the content?

Coding agents

Chat apps

Other

9:41

Verify Phone

OTP Input

Manual installation
  1. Install dependencies

    terminal
    npx expo install react-native-svg
  2. Copy the files

    Create each file at the same path and paste its code. @/ points at your src/ folder (the default in new Expo apps). Skip any file you already have, like lib/kit.tsx.

    • src/components/ui/otp-input.tsx381 lines
    • src/lib/kit.tsx220 lines

    Want to read it first? Every file is in Source below.

  3. Use it

    App.tsx
    import { OtpInput } from "@/components/ui/otp-input";

    See Usage for a full example.

Example.tsx
import { useState } from 'react';import { OtpInput } from '@/components/ui/otp-input'; const [code, setCode] = useState('');<OtpInput value={code} onChangeText={setCode} helperText="Sent to +1 415 555 0132" />

Every variant above has a complete, runnable example in its Code tab.

src/components/ui/otp-input.tsx
import React, { useEffect, useRef, useState, type ComponentRef } from 'react';import {  Animated,  Easing,  Platform,  Pressable,  StyleSheet,  Text,  TextInput,  View,  type StyleProp,  type ViewStyle,} from 'react-native';import Svg, { Circle, Path } from 'react-native-svg'; import { alpha, motion, useKit } from '@/lib/kit'; const AnimatedPath = Animated.createAnimatedComponent(Path);const CHECK_LEN = 16; export type OtpInputVariant = 'box' | 'underline' | 'pill'; export interface OtpInputProps {  /** Number of cells (default 6). */  length?: number;  value?: string;  onChangeText?: (code: string) => void;  /** Fires once every cell is filled (also after pasting a full code). */  onComplete?: (code: string) => void;  /** Shakes the row and turns it the danger colour. */  error?: boolean;  /** Message shown under the cells while `error` is set. */  errorText?: string;  /** Cells turn the success colour with a staggered pulse. */  success?: boolean;  /** Message shown under the cells while `success` is set. */  successText?: string;  /** Hint shown under the cells otherwise. */  helperText?: string;  /** Show dots instead of digits. */  secure?: boolean;  autoFocus?: boolean;  disabled?: boolean;  /** Active cell colour (default: theme accent). */  accent?: string;  /** 'box' cells, 'underline' bars, or round 'pill' cells. */  variant?: OtpInputVariant;  /** Draws a dash after this many cells, e.g. 3 → `123 – 456`. */  separatorAfter?: number;  /** Largest cell width in points; cells shrink below it to fit the row (default 52). */  maxCellSize?: number;  style?: StyleProp<ViewStyle>;} type CellState = 'idle' | 'error' | 'success'; const GAP = 8;/** react-native-web draws the browser focus outline; our border + halo is the focus indicator. */const webNoOutline = Platform.OS === 'web' ? ({ outlineStyle: 'none', outlineWidth: 0 } as object) : null;const SEP = 22; /** * One-time-code field. Cells size themselves to the available width, the active cell glows * with a focus halo and a blinking caret, digits pop in, a pasted code fills every cell, * errors shake the row and success pulses across the cells. */export function OtpInput({  length = 6,  value,  onChangeText,  onComplete,  error,  errorText,  success,  successText,  helperText,  secure,  autoFocus,  disabled,  accent,  variant = 'box',  separatorAfter,  maxCellSize = 52,  style,}: OtpInputProps) {  const { colors } = useKit();  const input = useRef<ComponentRef<typeof TextInput>>(null);  const [inner, setInner] = useState('');  const [focused, setFocused] = useState(false);  const [width, setWidth] = useState(0);  const code = (value ?? inner).slice(0, length);  const shake = useRef(new Animated.Value(0)).current;   useEffect(() => {    if (!error) return;    shake.setValue(0);    Animated.sequence(      [10, -9, 7, -5, 3, 0].map((x) => Animated.timing(shake, { toValue: x, duration: 45, useNativeDriver: true }))    ).start();  }, [error, shake]);   const change = (raw: string) => {    // No maxLength on the input, so pasting "123-456" or a longer SMS snippet still fills every cell.    const next = raw.replace(/\D/g, '').slice(0, length);    if (next === code) return;    if (value === undefined) setInner(next);    onChangeText?.(next);    if (next.length === length && code.length !== length) onComplete?.(next);  };   const sep = separatorAfter && separatorAfter > 0 && separatorAfter < length ? separatorAfter : 0;  // 4pt each side leaves room for the focus halo.  const free = width - 8 - GAP * (length - 1) - (sep ? SEP : 0);  const cell = width ? Math.max(24, Math.min(maxCellSize, Math.floor(free / length))) : maxCellSize;  const cellH = variant === 'pill' ? cell : variant === 'underline' ? Math.round(cell * 1.04) : Math.round(cell * 1.16);   const state: CellState = error ? 'error' : success ? 'success' : 'idle';  const tone = error ? colors.danger : success ? colors.success : accent ?? colors.accent;  const activeIndex = Math.min(code.length, length - 1);  const a11yLabel = `One-time code, ${length} digits`;  const message = error ? errorText : success ? successText : helperText;   return (    <View      onLayout={(e) => setWidth(Math.floor(e.nativeEvent.layout.width))}      style={[styles.root, disabled && styles.disabled, style]}    >      <Animated.View style={{ transform: [{ translateX: shake }] }}>        <Pressable          accessibilityRole="button"          accessibilityLabel={a11yLabel}          accessibilityHint="Activates the code field"          accessibilityState={{ disabled: !!disabled }}          disabled={disabled}          onPress={() => input.current?.focus()}          style={styles.row}        >          {Array.from({ length }, (_, i) => (            <React.Fragment key={i}>              {sep && i === sep ? (                <View style={[styles.sep, { width: SEP - GAP, height: cellH }]}>                  <View style={[styles.dash, { backgroundColor: colors.mutedForeground }]} />                </View>              ) : null}              <Cell                index={i}                char={code[i]}                secure={secure}                active={focused && i === activeIndex}                tone={tone}                state={state}                variant={variant}                w={cell}                h={cellH}              />            </React.Fragment>          ))}        </Pressable>      </Animated.View>      <TextInput        ref={input}        value={code}        onChangeText={change}        onFocus={() => setFocused(true)}        onBlur={() => setFocused(false)}        editable={!disabled}        keyboardType="number-pad"        textContentType="oneTimeCode"        autoComplete="one-time-code"        autoFocus={autoFocus}        caretHidden        accessibilityLabel={a11yLabel}        accessibilityHint={          error ? `Invalid code. Enter your ${length}-digit verification code` : `Enter your ${length}-digit verification code`        }        importantForAccessibility="yes"        style={[styles.hidden, webNoOutline]}      />      {message ? <Message text={message} kind={state} /> : null}    </View>  );} function Cell({  index,  char,  secure,  active,  tone,  state,  variant,  w,  h,}: {  index: number;  char?: string;  secure?: boolean;  active: boolean;  tone: string;  state: CellState;  variant: OtpInputVariant;  w: number;  h: number;}) {  const { colors, radius } = useKit();  const filled = !!char;  // Native driver: digit pop, caret blink, halo, success pulse.  const pop = useRef(new Animated.Value(filled ? 1 : 0)).current;  const caret = useRef(new Animated.Value(0)).current;  const halo = useRef(new Animated.Value(0)).current;  const pulse = useRef(new Animated.Value(0)).current;  // JS driver: border / fill colours.  const ring = useRef(new Animated.Value(0)).current;   useEffect(() => {    if (char) {      pop.setValue(0.3);      Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();    } else {      Animated.timing(pop, { toValue: 0, duration: 110, useNativeDriver: true }).start();    }  }, [char, pop]);   useEffect(() => {    const to = active ? 1 : 0;    Animated.spring(halo, { toValue: to, useNativeDriver: true, ...motion.snappy }).start();    Animated.timing(ring, { toValue: to, duration: motion.fast, useNativeDriver: false }).start();    if (!active) return;    caret.setValue(1);    const blink = Animated.loop(      Animated.sequence([        Animated.delay(450),        Animated.timing(caret, { toValue: 0, duration: 140, useNativeDriver: true }),        Animated.delay(300),        Animated.timing(caret, { toValue: 1, duration: 140, useNativeDriver: true }),      ])    );    blink.start();    return () => blink.stop();  }, [active, halo, ring, caret]);   useEffect(() => {    if (state !== 'success') return;    pulse.setValue(0);    Animated.sequence([      Animated.delay(index * 55),      Animated.timing(pulse, { toValue: 1, duration: 140, easing: Easing.out(Easing.quad), useNativeDriver: true }),      Animated.spring(pulse, { toValue: 0, useNativeDriver: true, ...motion.bouncy }),    ]).start();  }, [state, index, pulse]);   const underline = variant === 'underline';  const pill = variant === 'pill';  // Box cells follow KitProvider radius; pill stays round; underline has no corners.  const r = pill ? h / 2 : underline ? 0 : radius.md;  const rest = pill ? 'transparent' : filled ? alpha(colors.foreground, 0.32) : colors.input;  const borderColor =    state !== 'idle' ? tone : ring.interpolate({ inputRange: [0, 1], outputRange: [rest, tone] });  const fill =    state === 'success'      ? alpha(colors.success, 0.12)      : state === 'error'        ? alpha(colors.danger, 0.08)        : underline          ? 'transparent'          : pill            ? colors.muted            : colors.card;  const fg = state === 'idle' ? colors.foreground : tone;  const font = Math.round(w * 0.46);  const dot = Math.max(8, Math.round(w * 0.22));   return (    <Animated.View      accessibilityElementsHidden      importantForAccessibility="no-hide-descendants"      style={{        width: w,        height: h,        transform: [{ scale: pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 1.1] }) }],      }}    >      <Animated.View        pointerEvents="none"        style={[          styles.halo,          {            borderRadius: underline ? 10 : r + 4,            backgroundColor: alpha(tone, 0.18),            opacity: halo,            transform: [{ scale: halo.interpolate({ inputRange: [0, 1], outputRange: [0.86, 1] }) }],          },        ]}      />      <Animated.View        style={[          styles.cell,          underline            ? { borderBottomWidth: 2.5, borderColor, backgroundColor: fill }            : { borderWidth: 1.5, borderRadius: r, borderColor, backgroundColor: fill },        ]}      >        {filled ? (          <Animated.View style={{ opacity: pop, transform: [{ scale: pop }] }}>            {secure ? (              <View style={{ width: dot, height: dot, borderRadius: dot / 2, backgroundColor: fg }} />            ) : (              <Text style={[styles.char, { color: fg, fontSize: font, lineHeight: Math.round(font * 1.2) }]}>{char}</Text>            )}          </Animated.View>        ) : active ? (          <Animated.View style={[styles.caret, { height: Math.round(h * 0.42), backgroundColor: tone, opacity: caret }]} />        ) : null}      </Animated.View>    </Animated.View>  );} /** Helper / error / success line that fades and slides in whenever its text changes. */function Message({ text, kind }: { text: string; kind: CellState }) {  const { colors } = useKit();  const enter = useRef(new Animated.Value(0)).current;  const draw = useRef(new Animated.Value(0)).current;   useEffect(() => {    enter.setValue(0);    Animated.timing(enter, { toValue: 1, duration: motion.normal, easing: Easing.out(Easing.cubic), useNativeDriver: true }).start();    draw.setValue(0);    if (kind === 'success') {      Animated.timing(draw, { toValue: 1, duration: 360, delay: 120, easing: Easing.out(Easing.cubic), useNativeDriver: false }).start();    }  }, [text, kind, enter, draw]);   const color = kind === 'error' ? colors.danger : kind === 'success' ? colors.success : colors.mutedForeground;  return (    <Animated.View      accessibilityLiveRegion="polite"      style={[        styles.message,        { opacity: enter, transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [4, 0] }) }] },      ]}    >      {kind === 'error' ? (        <Svg width={14} height={14} viewBox="0 0 24 24" fill="none">          <Circle cx={12} cy={12} r={9} stroke={color} strokeWidth={1.8} />          <Path d="M12 7.6v5.2M12 16.3v.1" stroke={color} strokeWidth={1.8} strokeLinecap="round" />        </Svg>      ) : kind === 'success' ? (        <Svg width={14} height={14} viewBox="0 0 24 24" fill="none">          <Circle cx={12} cy={12} r={9} stroke={color} strokeWidth={1.8} />          <AnimatedPath            d="M8 12.3l2.7 2.7L16 9.6"            stroke={color}            strokeWidth={1.8}            strokeLinecap="round"            strokeLinejoin="round"            strokeDasharray={CHECK_LEN}            strokeDashoffset={draw.interpolate({ inputRange: [0, 1], outputRange: [CHECK_LEN, 0] }) as unknown as number}          />        </Svg>      ) : null}      <Text style={[styles.messageText, { color }]}>{text}</Text>    </Animated.View>  );} const styles = StyleSheet.create({  root: { alignSelf: 'stretch' },  disabled: { opacity: 0.5 },  row: { flexDirection: 'row', gap: GAP, paddingHorizontal: 4, paddingVertical: 4, justifyContent: 'center', alignItems: 'center' },  halo: { position: 'absolute', top: -4, left: -4, right: -4, bottom: -4 },  cell: { flex: 1, alignItems: 'center', justifyContent: 'center' },  char: { fontWeight: '400', fontVariant: ['tabular-nums'], textAlign: 'center' },  caret: { width: 2, borderRadius: 1 },  sep: { alignItems: 'center', justifyContent: 'center' },  dash: { width: 10, height: 2, borderRadius: 1, opacity: 0.6 },  message: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 6 },  messageText: { fontSize: 12.5, lineHeight: 17, fontWeight: '400' },  hidden: { position: 'absolute', width: 1, height: 1, opacity: 0 },});