Code Field
A one-time-code field: digits pop into cells, a caret glides along, a wrong code shakes and a right one ripples.
import { useEffect, useRef, useState } from 'react';
import {
AccessibilityInfo,
StyleSheet,
Text,
TextInput,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withSequence,
withSpring,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import type { Palette } from '../../lib/kinetik/tokens/tokens';
export type CodeFieldStatus = 'idle' | 'error' | 'success';
export type CodeFieldProps = {
value: string;
/** Called with digits only, never longer than `length`. */
onChangeText: (value: string) => void;
/** Called once when the last digit is entered. */
onComplete?: (code: string) => void;
/** Number of digits. Defaults to 6. */
length?: number;
/** `error` shakes the row and tints it red; `success` sends a wave along it. Defaults to `idle`. */
status?: CodeFieldStatus;
/** Names the field for screen readers. Defaults to 'Verification code'. */
accessibilityLabel?: string;
/** Show dots instead of digits. Defaults to false. */
secure?: boolean;
autoFocus?: boolean;
/** Haptics on error and success. Defaults to true. */
haptics?: boolean;
disabled?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const CELL_H = 56;
const GAP = 8;
/**
* A one-time-code field drawn as a row of cells. Digits pop into place, a
* caret glides to the next cell, a wrong code shakes the row and a right one
* sends a ripple along it. One real text input sits on top, so paste, SMS
* autofill and the system keyboard all work as usual.
*/
export function CodeField({
value,
onChangeText,
onComplete,
length = 6,
status = 'idle',
accessibilityLabel = 'Verification code',
secure = false,
autoFocus = false,
haptics = true,
disabled = false,
testID,
style,
}: CodeFieldProps) {
const { colors, font, radius } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const input = useRef<TextInput>(null);
const [focused, setFocused] = useState(false);
const [width, setWidth] = useState(0);
const digits = value.replace(/\D/g, '').slice(0, length);
const active = Math.min(digits.length, length - 1);
const cellW = width > 0 ? (width - GAP * (length - 1)) / length : 0;
const shake = useSharedValue(0);
const wave = useSharedValue(0);
const caretX = useSharedValue(0);
const blink = useSharedValue(1);
useEffect(() => {
if (status === 'error') {
haptic('error');
AccessibilityInfo.announceForAccessibility('Code not accepted');
if (!reduced) {
shake.set(
withSequence(
withTiming(-10, { duration: 50 }),
withTiming(9, { duration: 70 }),
withTiming(-6, { duration: 70 }),
withTiming(4, { duration: 70 }),
withSpring(0, spring('snappy')),
),
);
}
}
if (status === 'success') {
haptic('success');
AccessibilityInfo.announceForAccessibility('Code accepted');
wave.set(0);
if (!reduced) wave.set(withTiming(1, { duration: 700, easing: easings.standard }));
}
}, [status, reduced, haptic, shake, wave]);
useEffect(() => {
const x = active * (cellW + GAP);
caretX.set(reduced ? x : withSpring(x, spring('snappy')));
}, [active, cellW, reduced, caretX]);
// The caret blinks only while the field has focus and is still waiting for digits.
const waiting = focused && digits.length < length && status !== 'success';
useEffect(() => {
if (!waiting || reduced) {
blink.set(1);
return;
}
blink.set(
withRepeat(
withSequence(
withDelay(450, withTiming(0, { duration: 120 })),
withDelay(300, withTiming(1, { duration: 120 })),
),
-1,
),
);
}, [waiting, reduced, blink]);
const change = (text: string) => {
const next = text.replace(/\D/g, '').slice(0, length);
if (next === digits) return;
onChangeText(next);
if (next.length === length) onComplete?.(next);
};
const row = useAnimatedStyle(() => ({ transform: [{ translateX: shake.get() }] }));
const caret = useAnimatedStyle(() => ({
opacity: waiting ? blink.get() : 0,
transform: [{ translateX: caretX.get() + cellW / 2 - 1 }],
}));
const tone =
status === 'error' ? colors.danger : status === 'success' ? colors.success : colors.azure;
return (
<View
testID={testID}
style={[{ opacity: disabled ? 0.45 : 1 }, style]}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
>
<Animated.View style={[styles.row, { gap: GAP }, row]} pointerEvents="none">
{Array.from({ length }, (_, i) => (
<Cell
key={i}
char={digits[i]}
secure={secure}
index={i}
count={length}
current={focused && i === active && status === 'idle'}
tone={tone}
status={status}
wave={wave}
reduced={reduced}
colors={colors}
radius={radius.md}
fontSize={font.size['2xl']}
fontWeight={font.weight.semibold}
/>
))}
{cellW > 0 ? (
<Animated.View style={[styles.caret, { backgroundColor: colors.azure }, caret]} />
) : null}
</Animated.View>
<TextInput
ref={input}
value={digits}
onChangeText={change}
editable={!disabled}
autoFocus={autoFocus}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
keyboardType="number-pad"
inputMode="numeric"
textContentType="oneTimeCode"
autoComplete="one-time-code"
maxLength={length}
caretHidden
contextMenuHidden={false}
selectionColor="transparent"
accessibilityLabel={accessibilityLabel}
accessibilityHint={`${length} digits`}
accessibilityState={{ disabled }}
style={[StyleSheet.absoluteFill, styles.input]}
/>
</View>
);
}
type CellProps = {
char: string | undefined;
secure: boolean;
index: number;
count: number;
current: boolean;
tone: string;
status: CodeFieldStatus;
wave: SharedValue<number>;
reduced: boolean;
colors: Palette;
radius: number;
fontSize: number;
fontWeight: TextStyle['fontWeight'];
};
function Cell({
char,
secure,
index,
count,
current,
tone,
status,
wave,
reduced,
colors,
radius,
fontSize,
fontWeight,
}: CellProps) {
const pop = useSharedValue(char ? 1 : 0);
const ring = useSharedValue(current ? 1 : 0);
const filled = char !== undefined;
useEffect(() => {
if (!filled) {
pop.set(withTiming(0, { duration: durations.quick }));
return;
}
pop.set(0);
pop.set(reduced ? 1 : withSpring(1, spring('bouncy')));
}, [char, filled, reduced, pop]);
useEffect(() => {
ring.set(withSpring(current || status !== 'idle' ? 1 : 0, spring('snappy', reduced)));
}, [current, status, reduced, ring]);
const cell = useAnimatedStyle(() => {
// Each cell lifts as the success wave passes over it.
const at = wave.get() * (count + 2) - index - 1;
const lift = at > 0 && at < 2 ? Math.sin((at / 2) * Math.PI) : 0;
return {
borderColor: interpolateColor(ring.get(), [0, 1], [colors.border, tone]),
backgroundColor: filled ? colors.surfaceRaised : colors.surface,
transform: [{ translateY: -lift * 6 }],
};
});
const glyph = useAnimatedStyle(() => ({
opacity: Math.min(1, pop.get() * 1.4),
transform: [{ translateY: (1 - pop.get()) * 10 }, { scale: 0.6 + pop.get() * 0.4 }],
}));
return (
<Animated.View style={[styles.cell, { borderRadius: radius }, cell]}>
<Animated.View style={glyph}>
{secure && filled ? (
<View style={[styles.dot, { backgroundColor: colors.text }]} />
) : (
<Text
style={{
color: status === 'error' ? colors.danger : colors.text,
fontSize,
fontWeight,
fontVariant: ['tabular-nums'],
}}
>
{char ?? ''}
</Text>
)}
</Animated.View>
</Animated.View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row' },
cell: {
flex: 1,
height: CELL_H,
borderWidth: 1.5,
alignItems: 'center',
justifyContent: 'center',
},
caret: {
position: 'absolute',
left: 0,
top: CELL_H / 2 - 12,
width: 2,
height: 24,
borderRadius: 1,
},
dot: { width: 12, height: 12, borderRadius: 6 },
// The real input covers the row so taps, long-press paste and autofill land on it.
input: { opacity: 0.02, color: 'transparent', fontSize: 1 },
});Installation
npx kinetik-ui add code-fieldpnpm dlx kinetik-ui add code-fieldbunx kinetik-ui add code-fieldyarn dlx kinetik-ui add code-fieldThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/code-field.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/code-field.tsx
Usage
import { CodeField } from '@/components/kinetik/code-field';
const [code, setCode] = useState('');
const [status, setStatus] = useState<'idle' | 'error' | 'success'>('idle');
<CodeField
value={code}
onChangeText={setCode}
onComplete={async (c) => setStatus((await verify(c)) ? 'success' : 'error')}
status={status}
autoFocus
/>;Set status to error to shake the row, or success to send a ripple along it. Set it back to idle when the person starts typing again.
When to use
- Verification codes sent by SMS, email or an authenticator app.
- Short numeric PINs, with
secure.
When not to use
- Codes with letters, or longer than about eight characters. Use a plain text field.
- Passwords. Use a password field, such as Password Meter.
Notes
- One real
TextInputcovers the cells, so SMS autofill (oneTimeCodeon iOS,one-time-codeon Android), paste and the system keyboard work as usual. Everything but digits is dropped. onCompletefires once, when the last digit lands. A pasted code that is too long is trimmed tolength.- The caret blinks only while the field is focused and waiting for digits.
- With reduced motion the digits appear in place and results change colour without moving.
Props
CodeFieldProps
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | string | — | |
onChangeTextrequired | (value: string) => void | — | Called with digits only, never longer than `length`. |
onComplete | (code: string) => void | — | Called once when the last digit is entered. |
length | number | 6 | Number of digits. Defaults to 6. |
status | CodeFieldStatus | 'idle' | `error` shakes the row and tints it red; `success` sends a wave along it. Defaults to `idle`. |
accessibilityLabel | string | 'Verification code' | Names the field for screen readers. Defaults to 'Verification code'. |
secure | boolean | false | Show dots instead of digits. Defaults to false. |
autoFocus | boolean | false | |
haptics | boolean | true | Haptics on error and success. Defaults to true. |
disabled | boolean | false | |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- One text input and a row of plain views. While focused and empty the caret blinks (one opacity value); otherwise nothing runs.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 0.85× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 148 MB app memory. The long p99 frame is the system keyboard opening when the field takes focus.
Real-device measurement pending.
Accessibility
- Reduced motion
- Digits appear in place, the caret stays lit, and results change colour without the shake or the ripple.
- Screen readers
- One text field named by accessibilityLabel, with the number of digits as its hint. Accepted and rejected codes are announced.
- Touch target
- The cells are 56 pt tall and the input covers the whole row.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/code-field.tsx
import { useEffect, useRef, useState } from 'react';
import {
AccessibilityInfo,
StyleSheet,
Text,
TextInput,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withSequence,
withSpring,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import type { Palette } from '../../lib/kinetik/tokens/tokens';
export type CodeFieldStatus = 'idle' | 'error' | 'success';
export type CodeFieldProps = {
value: string;
/** Called with digits only, never longer than `length`. */
onChangeText: (value: string) => void;
/** Called once when the last digit is entered. */
onComplete?: (code: string) => void;
/** Number of digits. Defaults to 6. */
length?: number;
/** `error` shakes the row and tints it red; `success` sends a wave along it. Defaults to `idle`. */
status?: CodeFieldStatus;
/** Names the field for screen readers. Defaults to 'Verification code'. */
accessibilityLabel?: string;
/** Show dots instead of digits. Defaults to false. */
secure?: boolean;
autoFocus?: boolean;
/** Haptics on error and success. Defaults to true. */
haptics?: boolean;
disabled?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const CELL_H = 56;
const GAP = 8;
/**
* A one-time-code field drawn as a row of cells. Digits pop into place, a
* caret glides to the next cell, a wrong code shakes the row and a right one
* sends a ripple along it. One real text input sits on top, so paste, SMS
* autofill and the system keyboard all work as usual.
*/
export function CodeField({
value,
onChangeText,
onComplete,
length = 6,
status = 'idle',
accessibilityLabel = 'Verification code',
secure = false,
autoFocus = false,
haptics = true,
disabled = false,
testID,
style,
}: CodeFieldProps) {
const { colors, font, radius } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const input = useRef<TextInput>(null);
const [focused, setFocused] = useState(false);
const [width, setWidth] = useState(0);
const digits = value.replace(/\D/g, '').slice(0, length);
const active = Math.min(digits.length, length - 1);
const cellW = width > 0 ? (width - GAP * (length - 1)) / length : 0;
const shake = useSharedValue(0);
const wave = useSharedValue(0);
const caretX = useSharedValue(0);
const blink = useSharedValue(1);
useEffect(() => {
if (status === 'error') {
haptic('error');
AccessibilityInfo.announceForAccessibility('Code not accepted');
if (!reduced) {
shake.set(
withSequence(
withTiming(-10, { duration: 50 }),
withTiming(9, { duration: 70 }),
withTiming(-6, { duration: 70 }),
withTiming(4, { duration: 70 }),
withSpring(0, spring('snappy')),
),
);
}
}
if (status === 'success') {
haptic('success');
AccessibilityInfo.announceForAccessibility('Code accepted');
wave.set(0);
if (!reduced) wave.set(withTiming(1, { duration: 700, easing: easings.standard }));
}
}, [status, reduced, haptic, shake, wave]);
useEffect(() => {
const x = active * (cellW + GAP);
caretX.set(reduced ? x : withSpring(x, spring('snappy')));
}, [active, cellW, reduced, caretX]);
// The caret blinks only while the field has focus and is still waiting for digits.
const waiting = focused && digits.length < length && status !== 'success';
useEffect(() => {
if (!waiting || reduced) {
blink.set(1);
return;
}
blink.set(
withRepeat(
withSequence(
withDelay(450, withTiming(0, { duration: 120 })),
withDelay(300, withTiming(1, { duration: 120 })),
),
-1,
),
);
}, [waiting, reduced, blink]);
const change = (text: string) => {
const next = text.replace(/\D/g, '').slice(0, length);
if (next === digits) return;
onChangeText(next);
if (next.length === length) onComplete?.(next);
};
const row = useAnimatedStyle(() => ({ transform: [{ translateX: shake.get() }] }));
const caret = useAnimatedStyle(() => ({
opacity: waiting ? blink.get() : 0,
transform: [{ translateX: caretX.get() + cellW / 2 - 1 }],
}));
const tone =
status === 'error' ? colors.danger : status === 'success' ? colors.success : colors.azure;
return (
<View
testID={testID}
style={[{ opacity: disabled ? 0.45 : 1 }, style]}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
>
<Animated.View style={[styles.row, { gap: GAP }, row]} pointerEvents="none">
{Array.from({ length }, (_, i) => (
<Cell
key={i}
char={digits[i]}
secure={secure}
index={i}
count={length}
current={focused && i === active && status === 'idle'}
tone={tone}
status={status}
wave={wave}
reduced={reduced}
colors={colors}
radius={radius.md}
fontSize={font.size['2xl']}
fontWeight={font.weight.semibold}
/>
))}
{cellW > 0 ? (
<Animated.View style={[styles.caret, { backgroundColor: colors.azure }, caret]} />
) : null}
</Animated.View>
<TextInput
ref={input}
value={digits}
onChangeText={change}
editable={!disabled}
autoFocus={autoFocus}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
keyboardType="number-pad"
inputMode="numeric"
textContentType="oneTimeCode"
autoComplete="one-time-code"
maxLength={length}
caretHidden
contextMenuHidden={false}
selectionColor="transparent"
accessibilityLabel={accessibilityLabel}
accessibilityHint={`${length} digits`}
accessibilityState={{ disabled }}
style={[StyleSheet.absoluteFill, styles.input]}
/>
</View>
);
}
type CellProps = {
char: string | undefined;
secure: boolean;
index: number;
count: number;
current: boolean;
tone: string;
status: CodeFieldStatus;
wave: SharedValue<number>;
reduced: boolean;
colors: Palette;
radius: number;
fontSize: number;
fontWeight: TextStyle['fontWeight'];
};
function Cell({
char,
secure,
index,
count,
current,
tone,
status,
wave,
reduced,
colors,
radius,
fontSize,
fontWeight,
}: CellProps) {
const pop = useSharedValue(char ? 1 : 0);
const ring = useSharedValue(current ? 1 : 0);
const filled = char !== undefined;
useEffect(() => {
if (!filled) {
pop.set(withTiming(0, { duration: durations.quick }));
return;
}
pop.set(0);
pop.set(reduced ? 1 : withSpring(1, spring('bouncy')));
}, [char, filled, reduced, pop]);
useEffect(() => {
ring.set(withSpring(current || status !== 'idle' ? 1 : 0, spring('snappy', reduced)));
}, [current, status, reduced, ring]);
const cell = useAnimatedStyle(() => {
// Each cell lifts as the success wave passes over it.
const at = wave.get() * (count + 2) - index - 1;
const lift = at > 0 && at < 2 ? Math.sin((at / 2) * Math.PI) : 0;
return {
borderColor: interpolateColor(ring.get(), [0, 1], [colors.border, tone]),
backgroundColor: filled ? colors.surfaceRaised : colors.surface,
transform: [{ translateY: -lift * 6 }],
};
});
const glyph = useAnimatedStyle(() => ({
opacity: Math.min(1, pop.get() * 1.4),
transform: [{ translateY: (1 - pop.get()) * 10 }, { scale: 0.6 + pop.get() * 0.4 }],
}));
return (
<Animated.View style={[styles.cell, { borderRadius: radius }, cell]}>
<Animated.View style={glyph}>
{secure && filled ? (
<View style={[styles.dot, { backgroundColor: colors.text }]} />
) : (
<Text
style={{
color: status === 'error' ? colors.danger : colors.text,
fontSize,
fontWeight,
fontVariant: ['tabular-nums'],
}}
>
{char ?? ''}
</Text>
)}
</Animated.View>
</Animated.View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row' },
cell: {
flex: 1,
height: CELL_H,
borderWidth: 1.5,
alignItems: 'center',
justifyContent: 'center',
},
caret: {
position: 'absolute',
left: 0,
top: CELL_H / 2 - 12,
width: 2,
height: 24,
borderRadius: 1,
},
dot: { width: 12, height: 12, borderRadius: 6 },
// The real input covers the row so taps, long-press paste and autofill land on it.
input: { opacity: 0.02, color: 'transparent', fontSize: 1 },
});