v0.1 public beta early access on GitHub
Home / Screens / auth-signup
Screen template

Signup Screen

Create-account flow with name/email/password fields, terms acceptance, and inline validation

pro Expo Go ✓ iosandroid gestures: tap haptics: light · medium · selection · error
$ npx lumioui add auth-signup
import { SignupScreen } from "@/screens/auth/signup";
Native packages installed6
@lumioui/ui@latest@lumioui/core@latestreact-native-safe-area-context@*react-native-reanimated@*react-native-svg@*nativewind@*
auth/signup.tsxsource · yours to edit
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";
agent prompt — paste into Claude Code, Cursor, Devin…
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
iPhone 17 · iOS
9:41
Live preview — the real screen via Expo Web · open full ↗