New-password form — segmented strength meter, live requirements checklist, confirm-match validation, and reset CTA
import * as React from "react";
import { View, Pressable, ScrollView, TextInput } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useRef, useState } from "react";
import { Button, Icon, Input, Text, KeyboardAvoiding } from "@lumioui/ui";
import { useHaptic } from "@lumioui/core";
import { Mount, World } from "../shared";
export interface PasswordResetScreenProps {
/** The account being reset — shown under the title. */
email?: string;
onReset?: (password: string) => void;
onBack?: () => void;
}
interface Rule {
key: string;
label: string;
test: (password: string) => boolean;
}
const RULES: Rule[] = [
{ key: "length", label: "At least 8 characters", test: (p) => p.length >= 8 },
{ key: "case", label: "Upper and lowercase letters", test: (p) => /[a-z]/.test(p) && /[A-Z]/.test(p) },
{ key: "number", label: "A number", test: (p) => /\d/.test(p) },
{ key: "symbol", label: "A symbol", test: (p) => /[^a-zA-Z0-9]/.test(p) },
];
const STRENGTH_LABELS = ["Too weak", "Weak", "Fair", "Strong", "Very strong"];
/** Password reset — new-password form with a segmented strength meter and a
* live requirements checklist that ticks off as the user types. */
export function PasswordResetScreen({
email = "amara.chen@lumio.dev",
onReset,
onBack,
}: PasswordResetScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const confirmRef = useRef<TextInput>(null);
const [password, setPassword] = useState("Vanta#2026lumio");
const [confirm, setConfirm] = useState("Vanta#2026lumio");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [mismatch, setMismatch] = useState(false);
const passed = RULES.filter((r) => r.test(password)).length;
const score = password.length === 0 ? 0 : passed;
const allPassed = passed === RULES.length;
const matches = confirm.length === 0 || confirm === password;
const canSubmit = allPassed && confirm === password && password.length > 0;
const submit = () => {
if (confirm !== password) {
setMismatch(true);
haptics.error();
return;
}
setMismatch(false);
setLoading(true);
haptics.medium();
onReset?.(password);
setTimeout(() => {
setLoading(false);
haptics.success();
}, 800);
};
return (
<World primary="#65A30D">
<View className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]">
{/* Top bar — back affordance clears the status bar */}
<View className="px-2" style={{ paddingTop: insets.top + 4 }}>
<Pressable
onPress={() => {
haptics.light();
onBack?.();
}}
accessibilityRole="button"
accessibilityLabel="Go back"
hitSlop={8}
className="h-11 flex-row items-center gap-1 self-start px-2"
>
<Icon name="chevron-left" size="sm" color="#65A30D" />
<Text className="text-base text-[#65A30D]">Back</Text>
</Pressable>
</View>
<KeyboardAvoiding>
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
contentContainerClassName="flex-grow px-6"
contentContainerStyle={{ paddingTop: 8, paddingBottom: insets.bottom + 24 }}
>
<Mount delay={0}>
<View className="gap-3">
<Text variant="h1" className="tracking-tight">
Set a new <Text className="italic text-[#65A30D]">password</Text>
</Text>
<Text variant="muted" className="max-w-xs text-base leading-relaxed">
For <Text className="font-semibold text-foreground">{email}</Text> —
pick something you haven't used here before.
</Text>
</View>
</Mount>
<Mount delay={140}>
<View className="mt-8 gap-4">
<View>
<Input
label="New password"
placeholder="New password"
leftIcon={<Icon name="lock" size="sm" color="mutedForeground" />}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
autoComplete="new-password"
textContentType="newPassword"
returnKeyType="next"
onSubmitEditing={() => confirmRef.current?.focus()}
className="pr-11"
glass
/>
<Pressable
onPress={() => {
haptics.selection();
setShowPassword((v) => !v);
}}
accessibilityRole="button"
accessibilityLabel={showPassword ? "Hide password" : "Show password"}
accessibilityState={{ selected: showPassword }}
hitSlop={4}
className="absolute right-1 top-7 h-11 w-11 items-center justify-center rounded-full"
>
<Icon name={showPassword ? "eye-off" : "eye"} size="sm" color="mutedForeground" />
</Pressable>
</View>
<Input
ref={confirmRef}
label="Confirm password"
placeholder="Repeat it"
leftIcon={<Icon name="lock" size="sm" color="mutedForeground" />}
value={confirm}
onChangeText={(v) => {
setConfirm(v);
if (mismatch) setMismatch(false);
}}
secureTextEntry={!showPassword}
autoComplete="new-password"
textContentType="newPassword"
returnKeyType="done"
onSubmitEditing={submit}
error={mismatch || !matches ? "Passwords don't match" : undefined}
glass
/>
</View>
</Mount>
{/* Strength meter — the meter is the feedback, not decoration */}
<Mount delay={220}>
<View className="mt-6 gap-2">
<View className="flex-row items-center justify-between">
<Text variant="muted" className="text-xs">
Password strength
</Text>
<Text
variant="small"
className={
score >= 3
? "text-xs font-semibold text-success"
: score >= 1
? "text-xs font-semibold text-warning"
: "text-xs font-semibold text-muted-foreground"
}
accessibilityLiveRegion="polite"
>
{STRENGTH_LABELS[Math.min(score, 4)]}
</Text>
</View>
<View
className="flex-row gap-1.5"
accessibilityRole="progressbar"
accessibilityLabel={`Password strength ${STRENGTH_LABELS[Math.min(score, 4)]}`}
accessibilityValue={{ min: 0, max: 4, now: score }}
>
{[0, 1, 2, 3].map((seg) => (
<View
key={seg}
className={
seg < score
? score >= 3
? "h-1.5 flex-1 rounded-full bg-success"
: "h-1.5 flex-1 rounded-full bg-warning"
: "h-1.5 flex-1 rounded-full bg-muted"
}
/>
))}
</View>
{/* Crack-time estimate — the concrete consequence users care
about, not an abstract score. */}
<Text variant="muted" className="text-xs">
{score >= 4
? "Centuries to crack — excellent."
: score === 3
? "~6 months to crack — good."
: score === 2
? "~3 weeks to crack — add a symbol."
: score === 1
? "Minutes to crack — too easy."
: "Instantly guessable."}
</Text>
</View>
</Mount>
{/* Requirements — a live checklist that ticks off while typing */}
<Mount delay={280}>
<View className="mt-5 gap-2.5 overflow-hidden rounded-2xl bg-white p-4 dark:bg-[#1A1A20]">
{RULES.map((rule) => {
const ok = rule.test(password);
return (
<View key={rule.key} className="flex-row items-center gap-2.5">
<Icon
name={ok ? "check-circle" : "x"}
size="sm"
color={ok ? "success" : "mutedForeground"}
/>
<Text
variant="small"
className={ok ? "text-foreground" : "text-muted-foreground"}
>
{rule.label}
</Text>
</View>
);
})}
</View>
</Mount>
<Mount delay={340} className="mt-auto">
<View className="pt-8">
<Button
size="lg"
loading={loading}
disabled={!canSubmit}
onPress={submit}
haptic={false}
className="bg-[#65A30D] w-full"
accessibilityLabel="Reset password"
>
Reset password
</Button>
</View>
</Mount>
</ScrollView>
</KeyboardAvoiding>
</View>
</World>
);
}
PasswordResetScreen.displayName = "PasswordResetScreen";
Recreate the "Password Reset Screen" screen in React Native using LumioUI.
Goal: New-password form — segmented strength meter, live requirements checklist, confirm-match validation, and reset CTA Match the reference implementation's layout, behavior, and motion exactly — this is a production screen, not a mockup.
## Setup
Fastest path — install the screen plus its component deps:
```bash
npx lumioui add auth-password-reset
```
Or pull just the components: `npx lumioui add button icon input keyboard-avoiding text`
Native packages:
- @lumioui/ui@latest
- @lumioui/core@latest
- react-native-safe-area-context@*
- react-native-reanimated@*
- react-native-svg@*
- nativewind@*
Stack assumptions: Expo SDK 54 / RN 0.81, Reanimated 4, NativeWind v4, TypeScript strict, Expo Go compatible.
## Components — how, where, when
### `button` — Tactile pill button with press spring, ink wash and async loading.
- `<Button>` ×1
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×5
### `input` — Labeled field with focus ring, error shake and success check.
- `<Input>` ×2
### `keyboard-avoiding` — View that lifts above the keyboard on both platforms.
- `<KeyboardAvoiding>` ×1
### `text` — Themed type scale from caption to display.
- `<Text>` ×9
## Conventions (non-negotiable)
- Styling via NativeWind `className` with semantic tokens only: `bg-background`, `text-foreground`, `bg-card`, `text-card-foreground`, `bg-secondary`, `bg-muted`, `text-muted-foreground`, `bg-accent`, `bg-destructive`, `border-border`. Hard-coded colors only inside media overlays (`text-white`, `bg-black/N`).
- Motion via the token presets from `@lumioui/core` (`spring.press`, `spring.surface`, `spring.glide`, `timing.fast`, `timing.slow`, …) — no bespoke spring configs or durations.
- Gate transform motion behind `useReducedMotion()`; keep opacity/color feedback, drop travel and overshoot.
- Haptics via `useHaptic()`: `light` on press, `selection` on toggle/select, `warning` on invalid input. Never import expo-haptics directly.
- Press feedback via `usePressAnimation()` (~0.97 scale), not `activeOpacity` tricks.
- a11y: `accessibilityRole` + `accessibilityLabel` on every control, touch targets ≥44pt.
- Entrance choreography: staggered `Mount` wrappers (delay 0/60/120…) so regions land in sequence instead of popping in at once.
- Wrap the screen in `SafeAreaView`; keep dark mode working via the semantic tokens.
## Deliverable
One file: `screens/auth/password-reset.tsx` exporting PasswordResetScreen. Preserve the props interface — the host app wires the callbacks (navigation, submit handlers) through them.
## Reference implementation
The LumioUI source for this screen — reproduce it faithfully:
```tsx
import * as React from "react";
import { View, Pressable, ScrollView, TextInput } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useRef, useState } from "react";
import { Button, Icon, Input, Text, KeyboardAvoiding } from "@lumioui/ui";
import { useHaptic } from "@lumioui/core";
import { Mount, World } from "../shared";
export interface PasswordResetScreenProps {
/** The account being reset — shown under the title. */
email?: string;
onReset?: (password: string) => void;
onBack?: () => void;
}
interface Rule {
key: string;
label: string;
test: (password: string) => boolean;
}
const RULES: Rule[] = [
{ key: "length", label: "At least 8 characters", test: (p) => p.length >= 8 },
{ key: "case", label: "Upper and lowercase letters", test: (p) => /[a-z]/.test(p) && /[A-Z]/.test(p) },
{ key: "number", label: "A number", test: (p) => /\d/.test(p) },
{ key: "symbol", label: "A symbol", test: (p) => /[^a-zA-Z0-9]/.test(p) },
];
const STRENGTH_LABELS = ["Too weak", "Weak", "Fair", "Strong", "Very strong"];
/** Password reset — new-password form with a segmented strength meter and a
* live requirements checklist that ticks off as the user types. */
export function PasswordResetScreen({
email = "amara.chen@lumio.dev",
onReset,
onBack,
}: PasswordResetScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const confirmRef = useRef<TextInput>(null);
const [password, setPassword] = useState("Vanta#2026lumio");
const [confirm, setConfirm] = useState("Vanta#2026lumio");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [mismatch, setMismatch] = useState(false);
const passed = RULES.filter((r) => r.test(password)).length;
const score = password.length === 0 ? 0 : passed;
const allPassed = passed === RULES.length;
const matches = confirm.length === 0 || confirm === password;
const canSubmit = allPassed && confirm === password && password.length > 0;
const submit = () => {
if (confirm !== password) {
setMismatch(true);
haptics.error();
return;
}
setMismatch(false);
setLoading(true);
haptics.medium();
onReset?.(password);
setTimeout(() => {
setLoading(false);
haptics.success();
}, 800);
};
return (
<World primary="#65A30D">
<View className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]">
{/* Top bar — back affordance clears the status bar */}
<View className="px-2" style={{ paddingTop: insets.top + 4 }}>
<Pressable
onPress={() => {
haptics.light();
onBack?.();
}}
accessibilityRole="button"
accessibilityLabel="Go back"
hitSlop={8}
className="h-11 flex-row items-center gap-1 self-start px-2"
>
<Icon name="chevron-left" size="sm" color="#65A30D" />
<Text className="text-base text-[#65A30D]">Back</Text>
</Pressable>
</View>
<KeyboardAvoiding>
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
contentContainerClassName="flex-grow px-6"
contentContainerStyle={{ paddingTop: 8, paddingBottom: insets.bottom + 24 }}
>
<Mount delay={0}>
<View className="gap-3">
<Text variant="h1" className="tracking-tight">
Set a new <Text className="italic text-[#65A30D]">password</Text>
</Text>
<Text variant="muted" className="max-w-xs text-base leading-relaxed">
For <Text className="font-semibold text-foreground">{email}</Text> —
pick something you haven't used here before.
</Text>
</View>
</Mount>
<Mount delay={140}>
<View className="mt-8 gap-4">
<View>
<Input
label="New password"
placeholder="New password"
leftIcon={<Icon name="lock" size="sm" color="mutedForeground" />}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
autoComplete="new-password"
textContentType="newPassword"
returnKeyType="next"
onSubmitEditing={() => confirmRef.current?.focus()}
className="pr-11"
glass
/>
<Pressable
onPress={() => {
haptics.selection();
setShowPassword((v) => !v);
}}
accessibilityRole="button"
accessibilityLabel={showPassword ? "Hide password" : "Show password"}
accessibilityState={{ selected: showPassword }}
hitSlop={4}
className="absolute right-1 top-7 h-11 w-11 items-center justify-center rounded-full"
>
<Icon name={showPassword ? "eye-off" : "eye"} size="sm" color="mutedForeground" />
</Pressable>
</View>
<Input
ref={confirmRef}
label="Confirm password"
placeholder="Repeat it"
leftIcon={<Icon name="lock" size="sm" color="mutedForeground" />}
value={confirm}
onChangeText={(v) => {
setConfirm(v);
if (mismatch) setMismatch(false);
}}
secureTextEntry={!showPassword}
autoComplete="new-password"
textContentType="newPassword"
returnKeyType="done"
onSubmitEditing={submit}
error={mismatch || !matches ? "Passwords don't match" : undefined}
glass
/>
</View>
</Mount>
{/* Strength meter — the meter is the feedback, not decoration */}
<Mount delay={220}>
<View className="mt-6 gap-2">
<View className="flex-row items-center justify-between">
<Text variant="muted" className="text-xs">
Password strength
</Text>
<Text
variant="small"
className={
score >= 3
? "text-xs font-semibold text-success"
: score >= 1
? "text-xs font-semibold text-warning"
: "text-xs font-semibold text-muted-foreground"
}
accessibilityLiveRegion="polite"
>
{STRENGTH_LABELS[Math.min(score, 4)]}
</Text>
</View>
<View
className="flex-row gap-1.5"
accessibilityRole="progressbar"
accessibilityLabel={`Password strength ${STRENGTH_LABELS[Math.min(score, 4)]}`}
accessibilityValue={{ min: 0, max: 4, now: score }}
>
{[0, 1, 2, 3].map((seg) => (
<View
key={seg}
className={
seg < score
? score >= 3
? "h-1.5 flex-1 rounded-full bg-success"
: "h-1.5 flex-1 rounded-full bg-warning"
: "h-1.5 flex-1 rounded-full bg-muted"
}
/>
))}
</View>
{/* Crack-time estimate — the concrete consequence users care
about, not an abstract score. */}
<Text variant="muted" className="text-xs">
{score >= 4
? "Centuries to crack — excellent."
: score === 3
? "~6 months to crack — good."
: score === 2
? "~3 weeks to crack — add a symbol."
: score === 1
? "Minutes to crack — too easy."
: "Instantly guessable."}
</Text>
</View>
</Mount>
{/* Requirements — a live checklist that ticks off while typing */}
<Mount delay={280}>
<View className="mt-5 gap-2.5 overflow-hidden rounded-2xl bg-white p-4 dark:bg-[#1A1A20]">
{RULES.map((rule) => {
const ok = rule.test(password);
return (
<View key={rule.key} className="flex-row items-center gap-2.5">
<Icon
name={ok ? "check-circle" : "x"}
size="sm"
color={ok ? "success" : "mutedForeground"}
/>
<Text
variant="small"
className={ok ? "text-foreground" : "text-muted-foreground"}
>
{rule.label}
</Text>
</View>
);
})}
</View>
</Mount>
<Mount delay={340} className="mt-auto">
<View className="pt-8">
<Button
size="lg"
loading={loading}
disabled={!canSubmit}
onPress={submit}
haptic={false}
className="bg-[#65A30D] w-full"
accessibilityLabel="Reset password"
>
Reset password
</Button>
</View>
</Mount>
</ScrollView>
</KeyboardAvoiding>
</View>
</World>
);
}
PasswordResetScreen.displayName = "PasswordResetScreen";
```
Screen: https://lumioui.dev/screens/auth-password-reset