# Password Meter

> A new-password field whose strength meter fills segment by segment while each rule below ticks off.

- Category: Inputs
- Version: 1.0.0
- Platforms: iOS, Android, web
- Dependencies: `react-native-reanimated`, `react-native-worklets`
- Requires: React Native New Architecture (Reanimated 4)

## Install

```bash
npx kinetik-ui add password-meter
```

With the shadcn CLI:

```bash
npx shadcn@latest add https://kinetik-ui.dev/r/password-meter.json
```

## Usage

```tsx
import { PasswordMeter, passwordStrength } from '@/components/kinetik/password-meter';

<PasswordMeter value={password} onChangeText={setPassword} placeholder="At least 8 characters" />;

const canContinue = passwordStrength(password) >= 3;
```

`passwordStrength` gives the same 0 to 4 score the meter shows, so a form can require Good or better before it continues.

## When to use

- Creating or changing a password: sign up, reset, account settings.
- Anywhere a password policy should be visible while typing rather than after a failed submit.

## When not to use

- Signing in. Strength does not matter there; use a plain password field.
- As your only check. Validate on the server too, and consider a breached-password list.

## Notes

- `rules` replaces the default four rules (length, mixed case, a number, a symbol). The score is the share of rules met, scaled to the four segments.
- The meter takes one colour for the overall strength, red through amber and blue to green.
- The field sets `textContentType="newPassword"` and `autoComplete="new-password"`, so password managers offer to generate one. Pass more input props through `inputProps`.
- A change of strength is announced, but not every keystroke.
- With reduced motion the meter and ticks change at once.

## PasswordMeterProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` |  |  |
| `onChangeText` | `(value: string) => void` |  |  |
| `label`? | `string` | `'Password'` | Shown above the field and names it. Defaults to 'Password'. |
| `placeholder`? | `string` |  |  |
| `rules`? | `readonly PasswordRule[]` | `defaultPasswordRules` | What a strong password needs. Defaults to length, mixed case, a number and a symbol. |
| `showRules`? | `boolean` | `true` | List the rules under the meter, each ticking off as it is met. Defaults to true. |
| `inputProps`? | `Omit<TextInputProps, 'value' \| 'onChangeText' \| 'secureTextEntry' \| 'style'>` |  | Passed to the text input, e.g. `onSubmitEditing` or `returnKeyType`. |
| `testID`? | `string` |  |  |
| `style`? | `StyleProp<ViewStyle>` |  |  |

## Accessibility

- Reduced motion: The meter and the rule ticks change at once, without filling or popping.
- Screen readers: A secure text field named by its label, a Show password / Hide password button, and each rule read as met or not met. Changes in strength are announced.
- Touch target: The field is 52 pt tall; the reveal button is at least 44 pt.

## Performance

One shared value for the meter and one per rule, changed on keystrokes only. Nothing runs at rest.

- 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 · 144 MB app memory
- Real-device measurement pending.

## Source

`components/kinetik/password-meter.tsx`

```tsx
import { useEffect, useRef, useState } from 'react';
import {
  AccessibilityInfo,
  Pressable,
  StyleSheet,
  Text,
  TextInput,
  View,
  type StyleProp,
  type TextInputProps,
  type ViewStyle,
} from 'react-native';
import Animated, {
  interpolateColor,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  type SharedValue,
} from 'react-native-reanimated';

import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget } from '../../lib/kinetik/tokens/tokens';

export type PasswordRule = { label: string; test: (password: string) => boolean };

export const defaultPasswordRules: readonly PasswordRule[] = [
  { label: 'At least 8 characters', test: (p) => p.length >= 8 },
  { label: 'Upper and lower case', test: (p) => /[a-z]/.test(p) && /[A-Z]/.test(p) },
  { label: 'A number', test: (p) => /\d/.test(p) },
  { label: 'A symbol', test: (p) => /[^A-Za-z0-9]/.test(p) },
];

/** Strength from 0 to 4: the share of rules a password meets, scaled to the four segments. */
export function passwordStrength(
  password: string,
  rules: readonly PasswordRule[] = defaultPasswordRules,
): number {
  if (!password || rules.length === 0) return 0;
  const met = rules.filter((r) => r.test(password)).length;
  return Math.round((met / rules.length) * 4);
}

const WORDS = ['Too short', 'Weak', 'Fair', 'Good', 'Strong'] as const;

export type PasswordMeterProps = {
  value: string;
  onChangeText: (value: string) => void;
  /** Shown above the field and names it. Defaults to 'Password'. */
  label?: string;
  placeholder?: string;
  /** What a strong password needs. Defaults to length, mixed case, a number and a symbol. */
  rules?: readonly PasswordRule[];
  /** List the rules under the meter, each ticking off as it is met. Defaults to true. */
  showRules?: boolean;
  /** Passed to the text input, e.g. `onSubmitEditing` or `returnKeyType`. */
  inputProps?: Omit<TextInputProps, 'value' | 'onChangeText' | 'secureTextEntry' | 'style'>;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

/**
 * A new-password field with a strength meter. Four segments fill one after
 * another as the password gets stronger, the colour warms from red to green,
 * and each rule below ticks off the moment it is met.
 */
export function PasswordMeter({
  value,
  onChangeText,
  label = 'Password',
  placeholder,
  rules = defaultPasswordRules,
  showRules = true,
  inputProps,
  testID,
  style,
}: PasswordMeterProps) {
  const { colors, font, space, radius } = useKinetikTheme();
  const reduced = useReduceMotion();
  const [visible, setVisible] = useState(false);
  const [focused, setFocused] = useState(false);
  const score = passwordStrength(value, rules);
  const word = value ? WORDS[score] : '';

  const level = useSharedValue(score);
  const focus = useSharedValue(0);
  useEffect(() => {
    level.set(withSpring(score, spring('smooth', reduced)));
  }, [score, reduced, level]);
  useEffect(() => {
    focus.set(withSpring(focused ? 1 : 0, spring('snappy', reduced)));
  }, [focused, reduced, focus]);

  // Speak the strength when it changes, not on every keystroke.
  const spoken = useRef('');
  useEffect(() => {
    if (!word || word === spoken.current) return;
    spoken.current = word;
    AccessibilityInfo.announceForAccessibility(`Password strength: ${word}`);
  }, [word]);

  const ramp = [colors.danger, colors.danger, colors.warning, colors.azure, colors.success];
  const field = useAnimatedStyle(() => ({
    borderColor: interpolateColor(focus.get(), [0, 1], [colors.border, colors.azure]),
  }));

  return (
    <View testID={testID} style={[{ gap: space[2] }, style]}>
      <Text style={{ color: colors.text, fontSize: font.size.sm, fontWeight: font.weight.medium }}>
        {label}
      </Text>
      <Animated.View
        style={[
          styles.field,
          { backgroundColor: colors.surface, borderRadius: radius.md, paddingLeft: space[4] },
          field,
        ]}
      >
        <TextInput
          {...inputProps}
          value={value}
          onChangeText={onChangeText}
          secureTextEntry={!visible}
          placeholder={placeholder}
          placeholderTextColor={colors.textSubtle}
          autoCapitalize="none"
          autoCorrect={false}
          autoComplete="new-password"
          textContentType="newPassword"
          onFocus={(e) => {
            setFocused(true);
            inputProps?.onFocus?.(e);
          }}
          onBlur={(e) => {
            setFocused(false);
            inputProps?.onBlur?.(e);
          }}
          accessibilityLabel={label}
          selectionColor={colors.azure}
          style={[styles.input, { color: colors.text, fontSize: font.size.md }]}
        />
        <Pressable
          onPress={() => setVisible((v) => !v)}
          accessibilityRole="button"
          accessibilityLabel={visible ? 'Hide password' : 'Show password'}
          style={({ pressed }) => [
            styles.reveal,
            { paddingHorizontal: space[4], opacity: pressed ? 0.6 : 1 },
          ]}
        >
          <Text
            style={{
              color: colors.azure,
              fontSize: font.size.sm,
              fontWeight: font.weight.semibold,
            }}
          >
            {visible ? 'Hide' : 'Show'}
          </Text>
        </Pressable>
      </Animated.View>

      <View style={styles.meterRow}>
        <View style={[styles.meter, { gap: 4 }]}>
          {[0, 1, 2, 3].map((i) => (
            <Segment
              key={i}
              index={i}
              level={level}
              ramp={ramp}
              track={colors.track}
              radius={radius.pill}
            />
          ))}
        </View>
        <Text
          accessibilityLiveRegion="polite"
          style={[
            styles.word,
            {
              color: value ? ramp[score] : colors.textSubtle,
              fontSize: font.size.xs,
              fontWeight: font.weight.semibold,
            },
          ]}
        >
          {word}
        </Text>
      </View>

      {showRules ? (
        <View style={{ gap: 6, marginTop: space[1] }}>
          {rules.map((rule) => (
            <Rule key={rule.label} label={rule.label} met={rule.test(value)} reduced={reduced} />
          ))}
        </View>
      ) : null}
    </View>
  );
}

function Segment({
  index,
  level,
  ramp,
  track,
  radius,
}: {
  index: number;
  level: SharedValue<number>;
  ramp: string[];
  track: string;
  radius: number;
}) {
  const fill = useAnimatedStyle(() => {
    const l = level.get();
    return {
      width: `${Math.max(0, Math.min(1, l - index)) * 100}%`,
      // The whole meter shares one colour, set by the overall strength.
      backgroundColor: interpolateColor(l, [0, 1, 2, 3, 4], ramp),
    };
  });
  return (
    <View style={[styles.segment, { backgroundColor: track, borderRadius: radius }]}>
      <Animated.View style={[styles.segmentFill, { borderRadius: radius }, fill]} />
    </View>
  );
}

function Rule({ label, met, reduced }: { label: string; met: boolean; reduced: boolean }) {
  const { colors, font } = useKinetikTheme();
  const on = useSharedValue(met ? 1 : 0);
  useEffect(() => {
    on.set(withSpring(met ? 1 : 0, spring('bouncy', reduced)));
  }, [met, reduced, on]);

  const dot = useAnimatedStyle(() => ({
    backgroundColor: interpolateColor(on.get(), [0, 1], [colors.track, colors.success]),
    transform: [{ scale: 1 + Math.sin(Math.max(0, Math.min(1, on.get())) * Math.PI) * 0.25 }],
  }));
  const tick = useAnimatedStyle(() => ({
    opacity: on.get(),
    transform: [{ rotate: '45deg' }, { scale: Math.max(0, on.get()) }],
  }));
  const text = useAnimatedStyle(() => ({
    color: interpolateColor(on.get(), [0, 1], [colors.textMuted, colors.text]),
  }));

  return (
    <View
      accessible
      accessibilityLabel={`${label}: ${met ? 'met' : 'not met'}`}
      style={styles.rule}
    >
      <Animated.View style={[styles.dot, dot]}>
        <Animated.View style={[styles.tick, { borderColor: colors.onEmber }, tick]} />
      </Animated.View>
      <Animated.Text style={[{ fontSize: font.size.sm }, text]}>{label}</Animated.Text>
    </View>
  );
}

const styles = StyleSheet.create({
  field: {
    flexDirection: 'row',
    alignItems: 'center',
    borderWidth: 1.5,
    minHeight: 52,
  },
  input: { flex: 1, paddingVertical: 12 },
  reveal: { minHeight: minTouchTarget, justifyContent: 'center' },
  meterRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
  meter: { flex: 1, flexDirection: 'row' },
  segment: { flex: 1, height: 4, overflow: 'hidden' },
  segmentFill: { height: '100%' },
  word: { minWidth: 64, textAlign: 'right' },
  rule: { flexDirection: 'row', alignItems: 'center', gap: 10 },
  dot: { width: 16, height: 16, borderRadius: 8, alignItems: 'center', justifyContent: 'center' },
  tick: { width: 4, height: 8, marginTop: -2, borderBottomWidth: 1.5, borderRightWidth: 1.5 },
});
```
