Email/password sign-in with validation, remember me, social buttons, and forgot-password link
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, Divider, Glass, Icon, Input, KeyboardAvoiding, Text } from "@lumioui/ui";
import { useHaptic, useTheme } from "@lumioui/core";
import { AppleLogo, BLOCK_STAGGER, GoogleLogo, HeroGradient, Mount, World } from "../shared";
export interface LoginScreenProps {
onLogin?: (email: string, password: string, remember: boolean) => void;
onSignUp?: () => void;
onForgotPassword?: () => void;
onSocialLogin?: (provider: "apple" | "google") => void;
logo?: React.ReactNode;
/** Prefilled field values for demos/previews — production hosts omit
* these so the form starts empty. */
defaultEmail?: string;
defaultPassword?: string;
defaultRemember?: boolean;
}
interface FieldErrors {
email?: string;
password?: string;
}
/** Login screen — icon-leading email/password fields, social providers,
* remember me, and a staggered mount entrance. */
export function LoginScreen({ onLogin, onSignUp, onForgotPassword, onSocialLogin, logo, defaultEmail, defaultPassword, defaultRemember }: LoginScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const { colors } = useTheme();
const passwordRef = useRef<TextInput>(null);
const [email, setEmail] = useState(defaultEmail ?? "");
const [password, setPassword] = useState(defaultPassword ?? "");
const [showPassword, setShowPassword] = useState(false);
const [remember, setRemember] = useState(defaultRemember ?? false);
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<FieldErrors>({});
const canSubmit = email.trim().length > 0 && password.length > 0;
const submit = () => {
const e: FieldErrors = {};
if (!email.includes("@")) e.email = "Enter a valid email address";
if (password.length < 6) e.password = "Password must be at least 6 characters";
setErrors(e);
if (Object.keys(e).length) {
haptics.error();
return;
}
setLoading(true);
onLogin?.(email, password, remember);
setTimeout(() => setLoading(false), 800);
};
return (
<World primary="#65A30D">
<View className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]">
{/* Ambient color field behind the form — glass surfaces need something
to refract, so the brand hue bleeds down past the hero. */}
<KeyboardAvoiding>
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
contentContainerClassName="flex-grow px-6"
contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
>
{/* Brand hero — deep indigo wash bleeding to the top edge (the
-mx-6 cancels the scroll padding). Signature moment: raw hex
is art direction here, like StoryChrome. */}
<Mount delay={0} className="-mx-6">
<View className="overflow-hidden bg-[#1B1B26] px-6 pb-7" style={{ paddingTop: insets.top + 28 }}>
<HeroGradient from="#23232E" to="#14141A" />
<View className="gap-4">
{logo ?? (
<Glass interactive effect="clear" className="h-12 w-12 items-center justify-center rounded-2xl">
<Icon name="zap" size="lg" color="#FFFFFF" />
</Glass>
)}
<View className="gap-2">
<Text className="text-[34px] font-bold leading-[38px] tracking-tight text-white">
Design systems{"\n"}for touch.
</Text>
<Text className="max-w-[240px] text-[15px] leading-relaxed text-white/70">
Springs, haptics, and choreography — shipped standard,
not bolted on.
</Text>
</View>
<View className="flex-row gap-2 pt-1">
{["Springs", "Haptics", "Motion"].map((chip) => (
<Glass interactive key={chip} effect="clear" sheen={0.6} className="rounded-full px-3 py-1.5">
<Text className="text-xs font-medium text-white">{chip}</Text>
</Glass>
))}
</View>
</View>
</View>
</Mount>
{/* Form — fields stagger in under the hero */}
<View className="mt-8 gap-4">
<Mount delay={0}>
<Text variant="h2" className="tracking-tight">Welcome <Text className="italic text-[#65A30D]">back</Text></Text>
</Mount>
<Mount delay={BLOCK_STAGGER}>
<Input
glass
label="Email"
placeholder="you@example.com"
leftIcon={<Icon name="mail" size="sm" color="mutedForeground" />}
value={email}
onChangeText={(v) => {
setEmail(v);
if (errors.email) setErrors((e) => ({ ...e, email: undefined }));
}}
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
textContentType="emailAddress"
returnKeyType="next"
onSubmitEditing={() => passwordRef.current?.focus()}
error={errors.email}
/>
</Mount>
<Mount delay={BLOCK_STAGGER * 2}>
<View>
<Input
glass
ref={passwordRef}
label="Password"
placeholder="Your password"
leftIcon={<Icon name="lock" size="sm" color="mutedForeground" />}
value={password}
onChangeText={(v) => {
setPassword(v);
if (errors.password) setErrors((e) => ({ ...e, password: undefined }));
}}
secureTextEntry={!showPassword}
autoComplete="password"
textContentType="password"
returnKeyType="done"
onSubmitEditing={submit}
error={errors.password}
className="pr-11"
/>
{/* Visibility toggle — overlays the field's right edge; pr-11 above reserves the space. */}
<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>
</Mount>
<Mount delay={BLOCK_STAGGER * 3}>
<View className="flex-row items-center justify-between">
{/* Whole row toggles — the checkbox inside is visual-only for a11y,
so there's a single 44pt "Remember me" checkbox stop. */}
<Pressable
onPress={() => {
haptics.selection();
setRemember((v) => !v);
}}
accessibilityRole="checkbox"
accessibilityState={{ checked: remember }}
accessibilityLabel="Remember me"
hitSlop={8}
className="min-h-11 flex-row items-center gap-2"
>
<Checkbox
checked={remember}
onCheckedChange={setRemember}
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no"
/>
<Text variant="small" className="text-muted-foreground">
Remember me
</Text>
</Pressable>
<Pressable
onPress={() => {
haptics.light();
onForgotPassword?.();
}}
accessibilityRole="link"
accessibilityLabel="Forgot password"
hitSlop={8}
className="min-h-11 justify-center"
>
<Text variant="small" className="text-[#65A30D]">
Forgot password?
</Text>
</Pressable>
</View>
</Mount>
<Mount delay={BLOCK_STAGGER * 4}>
<Button
size="lg"
loading={loading}
disabled={!canSubmit}
onPress={submit}
haptic="medium"
className="bg-[#65A30D] w-full"
>
Sign in
</Button>
{/* Returning-user fast path — biometric sign-in rides under the
primary button on every banking app. */}
<Pressable
onPress={() => {
haptics.medium();
onLogin?.("", "", true);
}}
accessibilityRole="button"
accessibilityLabel="Sign in with Face ID"
hitSlop={8}
className="mt-1 min-h-11 flex-row items-center justify-center gap-2 self-center overflow-hidden rounded-full px-5"
>
<Glass interactive effect="clear" sheen={0.5} className="absolute inset-0 rounded-full" pointerEvents="none" />
<Icon name="scan-face" size="sm" color="#65A30D" />
<Text variant="small" className="font-medium text-[#65A30D]">
Sign in with Face ID
</Text>
</Pressable>
</Mount>
</View>
{/* Divider — hairline with quiet caps label */}
<Mount delay={BLOCK_STAGGER * 5} className="mt-9">
<Divider
label="or continue with"
labelClassName="text-xs uppercase tracking-widest"
/>
</Mount>
{/* Social providers — liquid-glass pills with the real brand marks. */}
<Mount delay={BLOCK_STAGGER * 6}>
<View className="mt-7 flex-row gap-3">
<Button
variant="glass"
className="flex-1"
icon={<GoogleLogo />}
onPress={() => onSocialLogin?.("google")}
accessibilityLabel="Continue with Google"
>
Google
</Button>
<Button
variant="glass"
className="flex-1"
icon={<AppleLogo color={colors.foreground} />}
onPress={() => onSocialLogin?.("apple")}
accessibilityLabel="Continue with Apple"
>
Apple
</Button>
</View>
</Mount>
{/* Footer — pinned to the bottom of the viewport */}
<Mount delay={BLOCK_STAGGER * 7} className="mt-auto">
<View className="items-center pt-8">
<Pressable
onPress={() => {
haptics.light();
onSignUp?.();
}}
className="min-h-11 items-center justify-center px-2"
accessibilityRole="link"
accessibilityLabel="Sign up for a new account"
hitSlop={8}
>
<Text variant="muted">
Don't have an account?{" "}
<Text className="text-[#65A30D] font-semibold">Sign up</Text>
</Text>
</Pressable>
</View>
</Mount>
</ScrollView>
</KeyboardAvoiding>
</View>
</World>
);
}
Recreate the "Login Screen" screen in React Native using LumioUI.
Goal: Email/password sign-in with validation, remember me, social buttons, and forgot-password link 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-login
```
Or pull just the components: `npx lumioui add button checkbox divider 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>` ×3
### `checkbox` — Animated check box with haptic tick and error state.
- `<Checkbox>` ×1
### `divider` — Hairline rule with optional centered label, horizontal or vertical.
- `<Divider>` ×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>` ×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 — Ambient color field behind the form — glass surfaces need something to refract, so the brand hue bleeds down past the hero.
### `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/login.tsx` exporting LoginScreen. 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, Divider, Glass, Icon, Input, KeyboardAvoiding, Text } from "@lumioui/ui";
import { useHaptic, useTheme } from "@lumioui/core";
import { AppleLogo, BLOCK_STAGGER, GoogleLogo, HeroGradient, Mount, World } from "../shared";
export interface LoginScreenProps {
onLogin?: (email: string, password: string, remember: boolean) => void;
onSignUp?: () => void;
onForgotPassword?: () => void;
onSocialLogin?: (provider: "apple" | "google") => void;
logo?: React.ReactNode;
/** Prefilled field values for demos/previews — production hosts omit
* these so the form starts empty. */
defaultEmail?: string;
defaultPassword?: string;
defaultRemember?: boolean;
}
interface FieldErrors {
email?: string;
password?: string;
}
/** Login screen — icon-leading email/password fields, social providers,
* remember me, and a staggered mount entrance. */
export function LoginScreen({ onLogin, onSignUp, onForgotPassword, onSocialLogin, logo, defaultEmail, defaultPassword, defaultRemember }: LoginScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const { colors } = useTheme();
const passwordRef = useRef<TextInput>(null);
const [email, setEmail] = useState(defaultEmail ?? "");
const [password, setPassword] = useState(defaultPassword ?? "");
const [showPassword, setShowPassword] = useState(false);
const [remember, setRemember] = useState(defaultRemember ?? false);
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<FieldErrors>({});
const canSubmit = email.trim().length > 0 && password.length > 0;
const submit = () => {
const e: FieldErrors = {};
if (!email.includes("@")) e.email = "Enter a valid email address";
if (password.length < 6) e.password = "Password must be at least 6 characters";
setErrors(e);
if (Object.keys(e).length) {
haptics.error();
return;
}
setLoading(true);
onLogin?.(email, password, remember);
setTimeout(() => setLoading(false), 800);
};
return (
<World primary="#65A30D">
<View className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]">
{/* Ambient color field behind the form — glass surfaces need something
to refract, so the brand hue bleeds down past the hero. */}
<KeyboardAvoiding>
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
contentContainerClassName="flex-grow px-6"
contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
>
{/* Brand hero — deep indigo wash bleeding to the top edge (the
-mx-6 cancels the scroll padding). Signature moment: raw hex
is art direction here, like StoryChrome. */}
<Mount delay={0} className="-mx-6">
<View className="overflow-hidden bg-[#1B1B26] px-6 pb-7" style={{ paddingTop: insets.top + 28 }}>
<HeroGradient from="#23232E" to="#14141A" />
<View className="gap-4">
{logo ?? (
<Glass interactive effect="clear" className="h-12 w-12 items-center justify-center rounded-2xl">
<Icon name="zap" size="lg" color="#FFFFFF" />
</Glass>
)}
<View className="gap-2">
<Text className="text-[34px] font-bold leading-[38px] tracking-tight text-white">
Design systems{"\n"}for touch.
</Text>
<Text className="max-w-[240px] text-[15px] leading-relaxed text-white/70">
Springs, haptics, and choreography — shipped standard,
not bolted on.
</Text>
</View>
<View className="flex-row gap-2 pt-1">
{["Springs", "Haptics", "Motion"].map((chip) => (
<Glass interactive key={chip} effect="clear" sheen={0.6} className="rounded-full px-3 py-1.5">
<Text className="text-xs font-medium text-white">{chip}</Text>
</Glass>
))}
</View>
</View>
</View>
</Mount>
{/* Form — fields stagger in under the hero */}
<View className="mt-8 gap-4">
<Mount delay={0}>
<Text variant="h2" className="tracking-tight">Welcome <Text className="italic text-[#65A30D]">back</Text></Text>
</Mount>
<Mount delay={BLOCK_STAGGER}>
<Input
glass
label="Email"
placeholder="you@example.com"
leftIcon={<Icon name="mail" size="sm" color="mutedForeground" />}
value={email}
onChangeText={(v) => {
setEmail(v);
if (errors.email) setErrors((e) => ({ ...e, email: undefined }));
}}
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
textContentType="emailAddress"
returnKeyType="next"
onSubmitEditing={() => passwordRef.current?.focus()}
error={errors.email}
/>
</Mount>
<Mount delay={BLOCK_STAGGER * 2}>
<View>
<Input
glass
ref={passwordRef}
label="Password"
placeholder="Your password"
leftIcon={<Icon name="lock" size="sm" color="mutedForeground" />}
value={password}
onChangeText={(v) => {
setPassword(v);
if (errors.password) setErrors((e) => ({ ...e, password: undefined }));
}}
secureTextEntry={!showPassword}
autoComplete="password"
textContentType="password"
returnKeyType="done"
onSubmitEditing={submit}
error={errors.password}
className="pr-11"
/>
{/* Visibility toggle — overlays the field's right edge; pr-11 above reserves the space. */}
<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>
</Mount>
<Mount delay={BLOCK_STAGGER * 3}>
<View className="flex-row items-center justify-between">
{/* Whole row toggles — the checkbox inside is visual-only for a11y,
so there's a single 44pt "Remember me" checkbox stop. */}
<Pressable
onPress={() => {
haptics.selection();
setRemember((v) => !v);
}}
accessibilityRole="checkbox"
accessibilityState={{ checked: remember }}
accessibilityLabel="Remember me"
hitSlop={8}
className="min-h-11 flex-row items-center gap-2"
>
<Checkbox
checked={remember}
onCheckedChange={setRemember}
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no"
/>
<Text variant="small" className="text-muted-foreground">
Remember me
</Text>
</Pressable>
<Pressable
onPress={() => {
haptics.light();
onForgotPassword?.();
}}
accessibilityRole="link"
accessibilityLabel="Forgot password"
hitSlop={8}
className="min-h-11 justify-center"
>
<Text variant="small" className="text-[#65A30D]">
Forgot password?
</Text>
</Pressable>
</View>
</Mount>
<Mount delay={BLOCK_STAGGER * 4}>
<Button
size="lg"
loading={loading}
disabled={!canSubmit}
onPress={submit}
haptic="medium"
className="bg-[#65A30D] w-full"
>
Sign in
</Button>
{/* Returning-user fast path — biometric sign-in rides under the
primary button on every banking app. */}
<Pressable
onPress={() => {
haptics.medium();
onLogin?.("", "", true);
}}
accessibilityRole="button"
accessibilityLabel="Sign in with Face ID"
hitSlop={8}
className="mt-1 min-h-11 flex-row items-center justify-center gap-2 self-center overflow-hidden rounded-full px-5"
>
<Glass interactive effect="clear" sheen={0.5} className="absolute inset-0 rounded-full" pointerEvents="none" />
<Icon name="scan-face" size="sm" color="#65A30D" />
<Text variant="small" className="font-medium text-[#65A30D]">
Sign in with Face ID
</Text>
</Pressable>
</Mount>
</View>
{/* Divider — hairline with quiet caps label */}
<Mount delay={BLOCK_STAGGER * 5} className="mt-9">
<Divider
label="or continue with"
labelClassName="text-xs uppercase tracking-widest"
/>
</Mount>
{/* Social providers — liquid-glass pills with the real brand marks. */}
<Mount delay={BLOCK_STAGGER * 6}>
<View className="mt-7 flex-row gap-3">
<Button
variant="glass"
className="flex-1"
icon={<GoogleLogo />}
onPress={() => onSocialLogin?.("google")}
accessibilityLabel="Continue with Google"
>
Google
</Button>
<Button
variant="glass"
className="flex-1"
icon={<AppleLogo color={colors.foreground} />}
onPress={() => onSocialLogin?.("apple")}
accessibilityLabel="Continue with Apple"
>
Apple
</Button>
</View>
</Mount>
{/* Footer — pinned to the bottom of the viewport */}
<Mount delay={BLOCK_STAGGER * 7} className="mt-auto">
<View className="items-center pt-8">
<Pressable
onPress={() => {
haptics.light();
onSignUp?.();
}}
className="min-h-11 items-center justify-center px-2"
accessibilityRole="link"
accessibilityLabel="Sign up for a new account"
hitSlop={8}
>
<Text variant="muted">
Don't have an account?{" "}
<Text className="text-[#65A30D] font-semibold">Sign up</Text>
</Text>
</Pressable>
</View>
</Mount>
</ScrollView>
</KeyboardAvoiding>
</View>
</World>
);
}
```
Screen: https://lumioui.dev/screens/auth-login