Create-account flow with name/email/password fields, terms acceptance, and inline validation
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, Checkbox, Glass, Icon, Input, KeyboardAvoiding, Text, type InputProps } from "@lumioui/ui";
import { useHaptic } from "@lumioui/core";
import { BLOCK_STAGGER, HeroSheet, Mount, Overline, World } from "../shared";
export interface SignupScreenProps {
onSignUp?: (data: { name: string; email: string; password: string }) => void;
onLogin?: () => void;
onTermsPress?: () => void;
onPrivacyPress?: () => void;
logo?: React.ReactNode;
/** Prefilled field values for demos/previews — production hosts omit
* these so the form starts empty. */
defaultName?: string;
defaultEmail?: string;
defaultPassword?: string;
defaultTerms?: boolean;
}
interface FieldErrors {
name?: string;
email?: string;
password?: string;
confirm?: string;
terms?: string;
}
/** Signup — sheet-stack grammar: a brand-gradient masthead carries the
* pitch ("JOIN LUMIO" + display line) and the white form sheet pulls up
* over it, so the flow opens like a boarding pass, not a settings page.
* Signature: the live profile chip — as the name field fills, an avatar
* forms in the sheet head so the account-in-progress is visible. */
export function SignupScreen({ onSignUp, onLogin, onTermsPress, onPrivacyPress, logo, defaultName, defaultEmail, defaultPassword, defaultTerms }: SignupScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const emailRef = useRef<TextInput>(null);
const passwordRef = useRef<TextInput>(null);
const confirmRef = useRef<TextInput>(null);
const [name, setName] = useState(defaultName ?? "");
const [email, setEmail] = useState(defaultEmail ?? "");
const [password, setPassword] = useState(defaultPassword ?? "");
const [confirm, setConfirm] = useState(defaultPassword ?? "");
const [terms, setTerms] = useState(defaultTerms ?? false);
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<FieldErrors>({});
const canSubmit =
name.trim().length > 0 &&
email.trim().length > 0 &&
password.length > 0 &&
confirm.length > 0 &&
terms;
const clearError = (key: keyof FieldErrors) =>
setErrors((e) => (e[key] ? { ...e, [key]: undefined } : e));
const submit = () => {
const e: FieldErrors = {};
if (!name.trim()) e.name = "Name is required";
if (!email.includes("@")) e.email = "Enter a valid email address";
if (password.length < 8) e.password = "Use at least 8 characters";
if (confirm !== password) e.confirm = "Passwords don't match";
if (!terms) e.terms = "Please accept the terms to continue";
setErrors(e);
if (Object.keys(e).length) {
haptics.error();
return;
}
setLoading(true);
onSignUp?.({ name, email, password });
setTimeout(() => setLoading(false), 800);
};
const initials = name
.trim()
.split(/\s+/)
.map((w) => w[0])
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
return (
<World primary="#65A30D">
<View className="flex-1 bg-[#111114]">
{/* Masthead — brand zone the sheet overlaps. Dark-first: the gradient
is art direction (like StoryChrome), identical in both schemes. */}
<View style={{ paddingTop: insets.top + 12 }} className="px-6 pb-14">
<Mount delay={0}>
<View className="flex-row items-center gap-3">
{logo ?? (
<Glass interactive effect="clear" className="h-11 w-11 items-center justify-center rounded-2xl">
<Icon name="zap" size="md" color="#FFF" />
</Glass>
)}
<Overline light>Join Lumio</Overline>
</View>
<Text className="pt-4 text-4xl font-light tracking-tight text-white">
Create your{"\n"}account
</Text>
<Text className="pt-2 text-base leading-relaxed text-white/65">
Get started in less than a minute.
</Text>
</Mount>
</View>
{/* Form sheet — pulls up over the masthead; content scrolls inside. */}
<KeyboardAvoiding className="flex-1">
<View className="flex-1">
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
className="flex-1"
contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
>
<HeroSheet glass overlap={56} className="min-h-full px-6">
{/* Live profile chip — the account taking shape, in glass. */}
<Mount delay={BLOCK_STAGGER}>
<View className="mb-4 mt-2 flex-row items-center gap-3 overflow-hidden rounded-2xl p-3">
<Glass interactive effect="clear" sheen={0.5} className="absolute inset-0 rounded-2xl" pointerEvents="none" />
<View className="h-10 w-10 items-center justify-center rounded-full bg-[#65A30D]/15">
{initials ? (
<Text className="text-sm font-bold text-[#65A30D]">{initials}</Text>
) : (
<Icon name="user" size="sm" color="#65A30D" />
)}
</View>
<View className="flex-1">
<Text className="text-sm font-semibold" numberOfLines={1}>
{name.trim() || "Your name"}
</Text>
<Text variant="muted" className="text-xs" numberOfLines={1}>
{email.trim() || "you@example.com"}
</Text>
</View>
<View className="overflow-hidden rounded-full px-2.5 py-1">
<Glass interactive effect="clear" className="absolute inset-0 rounded-full" pointerEvents="none" />
<Text className="text-[10px] font-semibold uppercase tracking-wide text-[#65A30D]">
Preview
</Text>
</View>
</View>
</Mount>
<View className="gap-3">
<Mount delay={BLOCK_STAGGER * 1.5}>
<Input
glass
label="Full name"
placeholder="Jane Doe"
leftIcon={<Icon name="user" size="sm" color="mutedForeground" />}
value={name}
onChangeText={(v) => {
setName(v);
clearError("name");
}}
autoComplete="name"
textContentType="name"
returnKeyType="next"
onSubmitEditing={() => emailRef.current?.focus()}
error={errors.name}
/>
</Mount>
<Mount delay={BLOCK_STAGGER * 2}>
<Input
glass
ref={emailRef}
label="Email"
placeholder="you@example.com"
leftIcon={<Icon name="mail" size="sm" color="mutedForeground" />}
value={email}
onChangeText={(v) => {
setEmail(v);
clearError("email");
}}
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
textContentType="emailAddress"
returnKeyType="next"
onSubmitEditing={() => passwordRef.current?.focus()}
error={errors.email}
/>
</Mount>
<Mount delay={BLOCK_STAGGER * 2.5}>
<PasswordField
glass
ref={passwordRef}
label="Password"
placeholder="At least 8 characters"
leftIcon={<Icon name="lock" size="sm" color="mutedForeground" />}
value={password}
onChangeText={(v) => {
setPassword(v);
clearError("password");
}}
autoComplete="new-password"
textContentType="newPassword"
returnKeyType="next"
onSubmitEditing={() => confirmRef.current?.focus()}
error={errors.password}
onToggleVisibility={() => haptics.selection()}
/>
</Mount>
<Mount delay={BLOCK_STAGGER * 3}>
<PasswordField
glass
ref={confirmRef}
label="Confirm password"
placeholder="Repeat your password"
leftIcon={<Icon name="lock" size="sm" color="mutedForeground" />}
value={confirm}
onChangeText={(v) => {
setConfirm(v);
clearError("confirm");
}}
autoComplete="new-password"
textContentType="newPassword"
returnKeyType="done"
onSubmitEditing={submit}
error={errors.confirm}
onToggleVisibility={() => haptics.selection()}
/>
{/* Match indicator — the inline green tick once both
passwords agree, before the user ever submits. */}
{confirm.length > 0 && confirm === password ? (
<View className="mt-2 flex-row items-center gap-1.5 pl-1">
<Icon name="check-circle" size="sm" color="success" />
<Text variant="small" className="text-xs text-success">
Passwords match
</Text>
</View>
) : null}
</Mount>
{/* Terms — the whole row is the checkbox hit target; inner
Checkbox is visual-only so there's a single a11y stop. */}
<Mount delay={BLOCK_STAGGER * 3.5}>
<View>
<Pressable
onPress={() => {
haptics.selection();
setTerms((v) => !v);
clearError("terms");
}}
accessibilityRole="checkbox"
accessibilityState={{ checked: terms }}
accessibilityLabel="Accept the Terms and Privacy Policy"
hitSlop={8}
className="min-h-11 flex-row items-start gap-2 py-2"
>
<Checkbox
checked={terms}
onCheckedChange={setTerms}
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no"
/>
<Text variant="small" className="flex-1 text-muted-foreground">
I agree to the{" "}
<Text
className="text-[#65A30D] font-medium"
accessibilityRole="link"
onPress={() => {
haptics.light();
onTermsPress?.();
}}
>
Terms
</Text>{" "}
and{" "}
<Text
className="text-[#65A30D] font-medium"
accessibilityRole="link"
onPress={() => {
haptics.light();
onPrivacyPress?.();
}}
>
Privacy Policy
</Text>
</Text>
</Pressable>
{errors.terms ? (
<Text variant="small" className="mt-1 text-destructive" accessibilityLiveRegion="polite">
{errors.terms}
</Text>
) : null}
</View>
</Mount>
<Mount delay={BLOCK_STAGGER * 4}>
<Button
size="lg"
loading={loading}
disabled={!canSubmit}
onPress={submit}
haptic="medium"
className="bg-[#65A30D] w-full"
>
Create account
</Button>
<Pressable
onPress={() => {
haptics.light();
onLogin?.();
}}
className="min-h-11 items-center justify-center px-2"
accessibilityRole="link"
accessibilityLabel="Sign in to an existing account"
hitSlop={8}
>
<Text variant="muted">
Already have an account?{" "}
<Text className="text-[#65A30D] font-semibold">Sign in</Text>
</Text>
</Pressable>
</Mount>
</View>
</HeroSheet>
</ScrollView>
</View>
</KeyboardAvoiding>
</View>
</World>
);
}
interface PasswordFieldProps extends InputProps {
onToggleVisibility?: () => void;
}
/** Password input with a visibility affordance overlaid on the field's right edge.
* `pr-11` reserves space so typed text never runs under the toggle. */
const PasswordField = React.forwardRef<React.ElementRef<typeof TextInput>, PasswordFieldProps>(
function PasswordField({ onToggleVisibility, ...props }, ref) {
const [visible, setVisible] = useState(false);
return (
<View>
<Input ref={ref} {...props} secureTextEntry={!visible} className="pr-11" />
{/* Label (20px) + gap (6px) + half of (field 48 - target 44) → top-7 centers
the 44pt target inside the field row. */}
<Pressable
onPress={() => {
onToggleVisibility?.();
setVisible((v) => !v);
}}
accessibilityRole="button"
accessibilityLabel={visible ? "Hide password" : "Show password"}
accessibilityState={{ selected: visible }}
hitSlop={4}
className="absolute right-1 top-7 h-11 w-11 items-center justify-center rounded-full"
>
<Icon name={visible ? "eye-off" : "eye"} size="sm" color="mutedForeground" />
</Pressable>
</View>
);
}
);
PasswordField.displayName = "PasswordField";
Recreate the "Signup Screen" screen in React Native using LumioUI.
Goal: Create-account flow with name/email/password fields, terms acceptance, and inline validation 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-signup
```
Or pull just the components: `npx lumioui add button checkbox glass 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
### `checkbox` — Animated check box with haptic tick and error state.
- `<Checkbox>` ×1
### `glass` — iOS 26 liquid-glass surface — real glass via expo-glass-effect (Expo Go) or @callstack/liquid-glass (dev builds), SVG refraction + specular rim on web, frosted fallback elsewhere; GlassContainer melts adjacent panes into one blob
- `<Glass>` ×3
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×8
### `input` — Labeled field with focus ring, error shake and success check.
- `<Input>` ×3
### `keyboard-avoiding` — View that lifts above the keyboard on both platforms.
- `<KeyboardAvoiding>` ×1 — Form sheet — pulls up over the masthead; content scrolls inside.
### `text` — Themed type scale from caption to display.
- `<Text>` ×13
## 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/signup.tsx` exporting SignupScreen. 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, Checkbox, Glass, Icon, Input, KeyboardAvoiding, Text, type InputProps } from "@lumioui/ui";
import { useHaptic } from "@lumioui/core";
import { BLOCK_STAGGER, HeroSheet, Mount, Overline, World } from "../shared";
export interface SignupScreenProps {
onSignUp?: (data: { name: string; email: string; password: string }) => void;
onLogin?: () => void;
onTermsPress?: () => void;
onPrivacyPress?: () => void;
logo?: React.ReactNode;
/** Prefilled field values for demos/previews — production hosts omit
* these so the form starts empty. */
defaultName?: string;
defaultEmail?: string;
defaultPassword?: string;
defaultTerms?: boolean;
}
interface FieldErrors {
name?: string;
email?: string;
password?: string;
confirm?: string;
terms?: string;
}
/** Signup — sheet-stack grammar: a brand-gradient masthead carries the
* pitch ("JOIN LUMIO" + display line) and the white form sheet pulls up
* over it, so the flow opens like a boarding pass, not a settings page.
* Signature: the live profile chip — as the name field fills, an avatar
* forms in the sheet head so the account-in-progress is visible. */
export function SignupScreen({ onSignUp, onLogin, onTermsPress, onPrivacyPress, logo, defaultName, defaultEmail, defaultPassword, defaultTerms }: SignupScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const emailRef = useRef<TextInput>(null);
const passwordRef = useRef<TextInput>(null);
const confirmRef = useRef<TextInput>(null);
const [name, setName] = useState(defaultName ?? "");
const [email, setEmail] = useState(defaultEmail ?? "");
const [password, setPassword] = useState(defaultPassword ?? "");
const [confirm, setConfirm] = useState(defaultPassword ?? "");
const [terms, setTerms] = useState(defaultTerms ?? false);
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<FieldErrors>({});
const canSubmit =
name.trim().length > 0 &&
email.trim().length > 0 &&
password.length > 0 &&
confirm.length > 0 &&
terms;
const clearError = (key: keyof FieldErrors) =>
setErrors((e) => (e[key] ? { ...e, [key]: undefined } : e));
const submit = () => {
const e: FieldErrors = {};
if (!name.trim()) e.name = "Name is required";
if (!email.includes("@")) e.email = "Enter a valid email address";
if (password.length < 8) e.password = "Use at least 8 characters";
if (confirm !== password) e.confirm = "Passwords don't match";
if (!terms) e.terms = "Please accept the terms to continue";
setErrors(e);
if (Object.keys(e).length) {
haptics.error();
return;
}
setLoading(true);
onSignUp?.({ name, email, password });
setTimeout(() => setLoading(false), 800);
};
const initials = name
.trim()
.split(/\s+/)
.map((w) => w[0])
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
return (
<World primary="#65A30D">
<View className="flex-1 bg-[#111114]">
{/* Masthead — brand zone the sheet overlaps. Dark-first: the gradient
is art direction (like StoryChrome), identical in both schemes. */}
<View style={{ paddingTop: insets.top + 12 }} className="px-6 pb-14">
<Mount delay={0}>
<View className="flex-row items-center gap-3">
{logo ?? (
<Glass interactive effect="clear" className="h-11 w-11 items-center justify-center rounded-2xl">
<Icon name="zap" size="md" color="#FFF" />
</Glass>
)}
<Overline light>Join Lumio</Overline>
</View>
<Text className="pt-4 text-4xl font-light tracking-tight text-white">
Create your{"\n"}account
</Text>
<Text className="pt-2 text-base leading-relaxed text-white/65">
Get started in less than a minute.
</Text>
</Mount>
</View>
{/* Form sheet — pulls up over the masthead; content scrolls inside. */}
<KeyboardAvoiding className="flex-1">
<View className="flex-1">
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
className="flex-1"
contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
>
<HeroSheet glass overlap={56} className="min-h-full px-6">
{/* Live profile chip — the account taking shape, in glass. */}
<Mount delay={BLOCK_STAGGER}>
<View className="mb-4 mt-2 flex-row items-center gap-3 overflow-hidden rounded-2xl p-3">
<Glass interactive effect="clear" sheen={0.5} className="absolute inset-0 rounded-2xl" pointerEvents="none" />
<View className="h-10 w-10 items-center justify-center rounded-full bg-[#65A30D]/15">
{initials ? (
<Text className="text-sm font-bold text-[#65A30D]">{initials}</Text>
) : (
<Icon name="user" size="sm" color="#65A30D" />
)}
</View>
<View className="flex-1">
<Text className="text-sm font-semibold" numberOfLines={1}>
{name.trim() || "Your name"}
</Text>
<Text variant="muted" className="text-xs" numberOfLines={1}>
{email.trim() || "you@example.com"}
</Text>
</View>
<View className="overflow-hidden rounded-full px-2.5 py-1">
<Glass interactive effect="clear" className="absolute inset-0 rounded-full" pointerEvents="none" />
<Text className="text-[10px] font-semibold uppercase tracking-wide text-[#65A30D]">
Preview
</Text>
</View>
</View>
</Mount>
<View className="gap-3">
<Mount delay={BLOCK_STAGGER * 1.5}>
<Input
glass
label="Full name"
placeholder="Jane Doe"
leftIcon={<Icon name="user" size="sm" color="mutedForeground" />}
value={name}
onChangeText={(v) => {
setName(v);
clearError("name");
}}
autoComplete="name"
textContentType="name"
returnKeyType="next"
onSubmitEditing={() => emailRef.current?.focus()}
error={errors.name}
/>
</Mount>
<Mount delay={BLOCK_STAGGER * 2}>
<Input
glass
ref={emailRef}
label="Email"
placeholder="you@example.com"
leftIcon={<Icon name="mail" size="sm" color="mutedForeground" />}
value={email}
onChangeText={(v) => {
setEmail(v);
clearError("email");
}}
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
textContentType="emailAddress"
returnKeyType="next"
onSubmitEditing={() => passwordRef.current?.focus()}
error={errors.email}
/>
</Mount>
<Mount delay={BLOCK_STAGGER * 2.5}>
<PasswordField
glass
ref={passwordRef}
label="Password"
placeholder="At least 8 characters"
leftIcon={<Icon name="lock" size="sm" color="mutedForeground" />}
value={password}
onChangeText={(v) => {
setPassword(v);
clearError("password");
}}
autoComplete="new-password"
textContentType="newPassword"
returnKeyType="next"
onSubmitEditing={() => confirmRef.current?.focus()}
error={errors.password}
onToggleVisibility={() => haptics.selection()}
/>
</Mount>
<Mount delay={BLOCK_STAGGER * 3}>
<PasswordField
glass
ref={confirmRef}
label="Confirm password"
placeholder="Repeat your password"
leftIcon={<Icon name="lock" size="sm" color="mutedForeground" />}
value={confirm}
onChangeText={(v) => {
setConfirm(v);
clearError("confirm");
}}
autoComplete="new-password"
textContentType="newPassword"
returnKeyType="done"
onSubmitEditing={submit}
error={errors.confirm}
onToggleVisibility={() => haptics.selection()}
/>
{/* Match indicator — the inline green tick once both
passwords agree, before the user ever submits. */}
{confirm.length > 0 && confirm === password ? (
<View className="mt-2 flex-row items-center gap-1.5 pl-1">
<Icon name="check-circle" size="sm" color="success" />
<Text variant="small" className="text-xs text-success">
Passwords match
</Text>
</View>
) : null}
</Mount>
{/* Terms — the whole row is the checkbox hit target; inner
Checkbox is visual-only so there's a single a11y stop. */}
<Mount delay={BLOCK_STAGGER * 3.5}>
<View>
<Pressable
onPress={() => {
haptics.selection();
setTerms((v) => !v);
clearError("terms");
}}
accessibilityRole="checkbox"
accessibilityState={{ checked: terms }}
accessibilityLabel="Accept the Terms and Privacy Policy"
hitSlop={8}
className="min-h-11 flex-row items-start gap-2 py-2"
>
<Checkbox
checked={terms}
onCheckedChange={setTerms}
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no"
/>
<Text variant="small" className="flex-1 text-muted-foreground">
I agree to the{" "}
<Text
className="text-[#65A30D] font-medium"
accessibilityRole="link"
onPress={() => {
haptics.light();
onTermsPress?.();
}}
>
Terms
</Text>{" "}
and{" "}
<Text
className="text-[#65A30D] font-medium"
accessibilityRole="link"
onPress={() => {
haptics.light();
onPrivacyPress?.();
}}
>
Privacy Policy
</Text>
</Text>
</Pressable>
{errors.terms ? (
<Text variant="small" className="mt-1 text-destructive" accessibilityLiveRegion="polite">
{errors.terms}
</Text>
) : null}
</View>
</Mount>
<Mount delay={BLOCK_STAGGER * 4}>
<Button
size="lg"
loading={loading}
disabled={!canSubmit}
onPress={submit}
haptic="medium"
className="bg-[#65A30D] w-full"
>
Create account
</Button>
<Pressable
onPress={() => {
haptics.light();
onLogin?.();
}}
className="min-h-11 items-center justify-center px-2"
accessibilityRole="link"
accessibilityLabel="Sign in to an existing account"
hitSlop={8}
>
<Text variant="muted">
Already have an account?{" "}
<Text className="text-[#65A30D] font-semibold">Sign in</Text>
</Text>
</Pressable>
</Mount>
</View>
</HeroSheet>
</ScrollView>
</View>
</KeyboardAvoiding>
</View>
</World>
);
}
interface PasswordFieldProps extends InputProps {
onToggleVisibility?: () => void;
}
/** Password input with a visibility affordance overlaid on the field's right edge.
* `pr-11` reserves space so typed text never runs under the toggle. */
const PasswordField = React.forwardRef<React.ElementRef<typeof TextInput>, PasswordFieldProps>(
function PasswordField({ onToggleVisibility, ...props }, ref) {
const [visible, setVisible] = useState(false);
return (
<View>
<Input ref={ref} {...props} secureTextEntry={!visible} className="pr-11" />
{/* Label (20px) + gap (6px) + half of (field 48 - target 44) → top-7 centers
the 44pt target inside the field row. */}
<Pressable
onPress={() => {
onToggleVisibility?.();
setVisible((v) => !v);
}}
accessibilityRole="button"
accessibilityLabel={visible ? "Hide password" : "Show password"}
accessibilityState={{ selected: visible }}
hitSlop={4}
className="absolute right-1 top-7 h-11 w-11 items-center justify-center rounded-full"
>
<Icon name={visible ? "eye-off" : "eye"} size="sm" color="mutedForeground" />
</Pressable>
</View>
);
}
);
PasswordField.displayName = "PasswordField";
```
Screen: https://lumioui.dev/screens/auth-signup