Kinetik UI

Code Field

.md

A one-time-code field: digits pop into cells, a caret glides along, a wrong code shakes and a right one ripples.

v1.0.0Live on webreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add code-field

The 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

  1. Install dependencies
    npx expo install react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/code-field.tsx
    View source files

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 TextInput covers the cells, so SMS autofill (oneTimeCode on iOS, one-time-code on Android), paste and the system keyboard work as usual. Everything but digits is dropped.
  • onComplete fires once, when the last digit lands. A pasted code that is too long is trimmed to length.
  • 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

PropTypeDefaultDescription
valuerequiredstring—
onChangeTextrequired(value: string) => void—Called with digits only, never longer than `length`.
onComplete(code: string) => void—Called once when the last digit is entered.
lengthnumber6Number of digits. Defaults to 6.
statusCodeFieldStatus'idle'`error` shakes the row and tints it red; `success` sends a wave along it. Defaults to `idle`.
accessibilityLabelstring'Verification code'Names the field for screen readers. Defaults to 'Verification code'.
securebooleanfalseShow dots instead of digits. Defaults to false.
autoFocusbooleanfalse
hapticsbooleantrueHaptics on error and success. Defaults to true.
disabledbooleanfalse
testIDstring—
styleStyleProp<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 },
});