Auth
Passwordless sign-in: an email, a one-time code with a resend timer, and a check once you are in.
import { useEffect, useState, type ReactNode } from 'react';
import {
Keyboard,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import Animated, {
FadeIn,
FadeOut,
SlideInRight,
SlideOutLeft,
useAnimatedStyle,
useSharedValue,
withDelay,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { CodeField, type CodeFieldStatus } from '../code-field';
import { PressJelly } from '../press-jelly';
import { WordCascade } from '../word-cascade';
import { durations, easings, spring, timing } from '../../../lib/kinetik/motion/motion';
import { useReduceMotion } from '../../../lib/kinetik/hooks/use-reduce-motion';
import { useKinetikTheme } from '../../../lib/kinetik/tokens/theme';
import { minTouchTarget } from '../../../lib/kinetik/tokens/tokens';
export type AuthProps = {
/** Send a one-time code to the address. Reject with an Error to show its message. */
onSendCode: (email: string) => Promise<void> | void;
/** Check the code. Reject when it is wrong; the field shakes and clears. */
onVerify: (email: string, code: string) => Promise<void> | void;
/** Called by the button on the final step. */
onDone: () => void;
/** Digits in the code. Defaults to 6. */
codeLength?: number;
/** Seconds before the code can be sent again. Defaults to 30. */
resendAfter?: number;
/** Prefills the address. */
initialEmail?: string;
};
type Step = 'email' | 'code' | 'done';
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Passwordless sign-in in three steps that slide into each other: an email,
* a one-time code with a resend countdown, and a check that pops in once
* you are through.
*/
export function Auth({
onSendCode,
onVerify,
onDone,
codeLength = 6,
resendAfter = 30,
initialEmail = '',
}: AuthProps) {
const { colors, space } = useKinetikTheme();
const insets = useSafeAreaInsets();
const reduced = useReduceMotion();
const [step, setStep] = useState<Step>('email');
const [back, setBack] = useState(false);
const [email, setEmail] = useState(initialEmail);
const enter = reduced
? FadeIn.duration(durations.quick)
: back
? FadeIn.duration(durations.base).easing(easings.standard)
: SlideInRight.duration(durations.slow).easing(easings.standard);
const exit = reduced
? FadeOut.duration(durations.quick)
: back
? FadeOut.duration(durations.quick)
: SlideOutLeft.duration(durations.base).easing(easings.exit);
return (
<KeyboardAvoidingView
style={[styles.fill, { backgroundColor: colors.background }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
style={styles.fill}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
flexGrow: 1,
paddingTop: insets.top + space[6],
paddingBottom: insets.bottom + space[4],
paddingHorizontal: space[6],
}}
>
<Animated.View key={step} entering={enter} exiting={exit} style={styles.fill}>
{step === 'email' ? (
<EmailStep
email={email}
onChange={setEmail}
onSend={async (address) => {
await onSendCode(address);
setEmail(address);
setBack(false);
setStep('code');
}}
/>
) : step === 'code' ? (
<CodeStep
email={email}
length={codeLength}
resendAfter={resendAfter}
onResend={() => onSendCode(email)}
onVerify={(code) => onVerify(email, code)}
onVerified={() => {
Keyboard.dismiss();
setBack(false);
setStep('done');
}}
onChangeEmail={() => {
setBack(true);
setStep('email');
}}
/>
) : (
<DoneStep onDone={onDone} />
)}
</Animated.View>
</ScrollView>
</KeyboardAvoidingView>
);
}
function Heading({ title, body }: { title: string; body: ReactNode }) {
const { colors, space, font } = useKinetikTheme();
return (
<View style={{ gap: space[2] }}>
<WordCascade
text={title}
delay={120}
style={{
color: colors.text,
fontSize: font.size['2xl'],
fontWeight: font.weight.bold,
letterSpacing: font.tracking.tight,
}}
/>
<Text style={{ color: colors.textMuted, fontSize: font.size.md, lineHeight: 22 }}>
{body}
</Text>
</View>
);
}
/** A round badge with an envelope drawn from two views. */
function Envelope() {
const { colors, radius } = useKinetikTheme();
return (
<View
aria-hidden
style={[styles.badge, { backgroundColor: colors.surface, borderColor: colors.border }]}
>
<View style={[styles.letter, { borderColor: colors.text, borderRadius: radius.xs }]}>
<View style={[styles.flap, { borderColor: colors.text }]} />
</View>
</View>
);
}
function EmailStep({
email,
onChange,
onSend,
}: {
email: string;
onChange: (email: string) => void;
onSend: (email: string) => Promise<void>;
}) {
const { colors, space, font, radius } = useKinetikTheme();
const [busy, setBusy] = useState(false);
const [focused, setFocused] = useState(false);
const [error, setError] = useState<string | null>(null);
const send = async () => {
const address = email.trim();
if (!EMAIL.test(address)) {
setError('Enter a valid email address');
return;
}
setError(null);
setBusy(true);
try {
await onSend(address);
} catch (e) {
setError(e instanceof Error && e.message ? e.message : 'We could not send a code.');
setBusy(false);
}
};
return (
<View style={[styles.fill, { gap: space[6] }]}>
<Envelope />
<Heading
title="Sign in with a code"
body="We’ll email you a one-time code. No password to remember."
/>
<View style={{ gap: space[2] }}>
<Text
style={{ color: colors.text, fontSize: font.size.sm, fontWeight: font.weight.medium }}
>
Email
</Text>
<TextInput
value={email}
onChangeText={(text) => {
onChange(text);
if (error) setError(null);
}}
accessibilityLabel="Email"
placeholder="you@example.com"
placeholderTextColor={colors.textSubtle}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
autoComplete="email"
textContentType="emailAddress"
returnKeyType="send"
onSubmitEditing={send}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
editable={!busy}
selectionColor={colors.azure}
cursorColor={colors.azure}
style={[
styles.input,
{
color: colors.text,
fontSize: font.size.md,
backgroundColor: colors.surface,
borderColor: error ? colors.danger : focused ? colors.azure : colors.borderStrong,
borderRadius: radius.md,
paddingHorizontal: space[4],
},
]}
/>
{error ? (
<Text
accessibilityLiveRegion="polite"
style={{ color: colors.danger, fontSize: font.size.xs }}
>
{error}
</Text>
) : null}
</View>
<PressJelly block size="lg" loading={busy} onPress={busy ? undefined : send}>
Send code
</PressJelly>
</View>
);
}
function CodeStep({
email,
length,
resendAfter,
onResend,
onVerify,
onVerified,
onChangeEmail,
}: {
email: string;
length: number;
resendAfter: number;
onResend: () => Promise<void> | void;
onVerify: (code: string) => Promise<void> | void;
onVerified: () => void;
onChangeEmail: () => void;
}) {
const { colors, space, font } = useKinetikTheme();
const [code, setCode] = useState('');
const [status, setStatus] = useState<CodeFieldStatus>('idle');
const [checking, setChecking] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [left, setLeft] = useState(resendAfter);
// One tick per second while the resend link is locked.
useEffect(() => {
if (left <= 0) return;
const id = setTimeout(() => setLeft((s) => s - 1), 1000);
return () => clearTimeout(id);
}, [left]);
const verify = async (value: string) => {
setChecking(true);
setMessage(null);
try {
await onVerify(value);
setStatus('success');
// Let the success wave run along the digits before moving on.
setTimeout(onVerified, 650);
} catch {
setStatus('error');
setMessage('That code didn’t match. Try again.');
setTimeout(() => setCode(''), 450);
} finally {
setChecking(false);
}
};
const resend = async () => {
setMessage(null);
setLeft(resendAfter);
try {
await onResend();
setMessage('A new code is on its way.');
} catch {
setLeft(0);
setMessage('We could not send a new code.');
}
};
const clock = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
return (
<View style={[styles.fill, { gap: space[6] }]}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Change email"
onPress={onChangeEmail}
style={styles.backLink}
>
<Text
style={{
color: colors.textMuted,
fontSize: font.size.sm,
fontWeight: font.weight.medium,
}}
>
‹ Change email
</Text>
</Pressable>
<Heading
title="Check your inbox"
body={
<>
Enter the code we sent to{' '}
<Text style={{ color: colors.text, fontWeight: font.weight.semibold }}>{email}</Text>
</>
}
/>
<View style={{ gap: space[3] }}>
<CodeField
value={code}
onChangeText={(value) => {
setCode(value);
if (status === 'error') setStatus('idle');
}}
onComplete={verify}
length={length}
status={status}
disabled={checking || status === 'success'}
autoFocus
/>
<Text
accessibilityLiveRegion="polite"
style={{
minHeight: 18,
color: status === 'error' ? colors.danger : colors.textMuted,
fontSize: font.size.xs,
}}
>
{checking ? 'Checking…' : (message ?? '')}
</Text>
</View>
{left > 0 ? (
<Text
accessibilityLabel={`You can resend the code in ${left} seconds`}
style={[styles.resend, { color: colors.textSubtle, fontSize: font.size.sm }]}
>
Resend code in {clock}
</Text>
) : (
<Pressable accessibilityRole="button" onPress={resend} style={styles.resend}>
<Text
style={{
color: colors.text,
fontSize: font.size.sm,
fontWeight: font.weight.semibold,
textDecorationLine: 'underline',
}}
>
Resend code
</Text>
</Pressable>
)}
</View>
);
}
/** A check that pops into a filled circle while a ring breathes out once behind it. */
function DoneStep({ onDone }: { onDone: () => void }) {
const { colors, space, font } = useKinetikTheme();
const reduced = useReduceMotion();
const pop = useSharedValue(reduced ? 1 : 0);
const ring = useSharedValue(reduced ? 1 : 0);
useEffect(() => {
if (reduced) return;
pop.set(withDelay(160, withSpring(1, spring('bouncy'))));
ring.set(withDelay(260, withTiming(1, { ...timing('slow'), duration: 900 })));
}, [reduced, pop, ring]);
const badge = useAnimatedStyle(() => ({ transform: [{ scale: pop.get() }] }));
const tick = useAnimatedStyle(() => ({
opacity: Math.min(1, Math.max(0, pop.get() * 2 - 0.8)),
transform: [{ rotate: '-45deg' }, { scale: 0.6 + pop.get() * 0.4 }],
}));
const halo = useAnimatedStyle(() => ({
opacity: reduced ? 0 : (1 - ring.get()) * 0.6,
transform: [{ scale: 1 + ring.get() * 0.9 }],
}));
return (
<View style={[styles.fill, styles.done, { gap: space[6] }]}>
<View style={styles.doneArt}>
<Animated.View style={[styles.halo, { borderColor: colors.success }, halo]} />
<Animated.View style={[styles.doneBadge, { backgroundColor: colors.success }, badge]}>
<Animated.View style={[styles.tick, { borderColor: colors.background }, tick]} />
</Animated.View>
</View>
<View style={{ gap: space[2], alignItems: 'center' }}>
<Text
accessibilityRole="header"
style={{
color: colors.text,
fontSize: font.size['2xl'],
fontWeight: font.weight.bold,
letterSpacing: font.tracking.tight,
}}
>
You’re in
</Text>
<Text
style={{
color: colors.textMuted,
fontSize: font.size.md,
lineHeight: 22,
textAlign: 'center',
}}
>
Your email is verified. Next time it takes a single code.
</Text>
</View>
<PressJelly block size="lg" onPress={onDone} style={styles.doneButton}>
Continue
</PressJelly>
</View>
);
}
const styles = StyleSheet.create({
fill: { flex: 1 },
input: { minHeight: 52, borderWidth: 1 },
badge: {
width: 56,
height: 56,
borderRadius: 28,
borderWidth: StyleSheet.hairlineWidth,
alignItems: 'center',
justifyContent: 'center',
},
letter: { width: 24, height: 18, borderWidth: 1.75, overflow: 'hidden', alignItems: 'center' },
flap: {
width: 16,
height: 16,
marginTop: -10,
borderWidth: 1.75,
transform: [{ rotate: '45deg' }],
},
backLink: { minHeight: minTouchTarget, justifyContent: 'center', alignSelf: 'flex-start' },
resend: {
minHeight: minTouchTarget,
alignSelf: 'center',
justifyContent: 'center',
textAlignVertical: 'center',
},
done: { alignItems: 'center', justifyContent: 'center' },
doneArt: { width: 120, height: 120, alignItems: 'center', justifyContent: 'center' },
halo: { position: 'absolute', width: 96, height: 96, borderRadius: 48, borderWidth: 2 },
doneBadge: {
width: 96,
height: 96,
borderRadius: 48,
alignItems: 'center',
justifyContent: 'center',
},
tick: {
width: 34,
height: 18,
marginTop: -8,
borderLeftWidth: 5,
borderBottomWidth: 5,
borderRadius: 2,
},
doneButton: { alignSelf: 'stretch' },
});Installation
npx kinetik-ui add authpnpm dlx kinetik-ui add authbunx kinetik-ui add authyarn dlx kinetik-ui add authThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/auth.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-safe-area-context react-native-worklets - Copy the source files
components/kinetik/screens/auth.tsx
Usage
import { Auth } from '@/components/kinetik/screens/auth';
<Auth
onSendCode={(email) => auth.sendOtp({ email })}
onVerify={async (email, code) => {
const { error } = await auth.verifyOtp({ email, token: code });
if (error) throw error;
}}
onDone={() => router.replace('/home')}
/>;The demo accepts the code 123456.
When to use
- Passwordless sign-in or sign-up with a one-time code sent by email (Supabase, Clerk, Firebase email links, your own API).
- Confirming an address after sign-up.
When not to use
- Codes sent by SMS: the copy, keyboard and field label assume email. Change them before you reuse the flow.
- Accounts with passwords: use the Login screen.
Notes
onVerifyruns as soon as the last digit is entered. Reject to shake and clear the code; resolve to play the success wave and move to the final step.- Resend unlocks after
resendAfterseconds (default 30) and restarts the timer each time it is used. - Codes paste and autofill from the keyboard suggestion (Code Field handles one-time-code autofill).
Props
AuthProps
| Prop | Type | Default | Description |
|---|---|---|---|
onSendCoderequired | (email: string) => Promise<void> | void | — | Send a one-time code to the address. Reject with an Error to show its message. |
onVerifyrequired | (email: string, code: string) => Promise<void> | void | — | Check the code. Reject when it is wrong; the field shakes and clears. |
onDonerequired | () => void | — | Called by the button on the final step. |
codeLength | number | 6 | Digits in the code. Defaults to 6. |
resendAfter | number | 30 | Seconds before the code can be sent again. Defaults to 30. |
initialEmail | string | '' | Prefills the address. |
Performance and accessibility
Performance
- Budget
- Steps slide in with one layout animation each; the code row and the final check are UI-thread springs. The resend countdown re-renders once a second while it runs.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.00× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 154 MB app memory. Most janky frames are the software keyboard and the step slide on the emulator; the code row itself stays at the baseline.
Real-device measurement pending.
Accessibility
- Reduced motion
- Steps cross-fade instead of sliding, the check appears without popping or a ring, and the code row does not shake or wave (Code Field).
- Screen readers
- Each step leads with its title. The code field is named Verification code; checking, a wrong code and a resent code are announced. The countdown reads "You can resend the code in 24 seconds".
- Touch target
- The email field is 52 pt; buttons and links are at least 44 pt.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/screens/auth.tsx
import { useEffect, useState, type ReactNode } from 'react';
import {
Keyboard,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import Animated, {
FadeIn,
FadeOut,
SlideInRight,
SlideOutLeft,
useAnimatedStyle,
useSharedValue,
withDelay,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { CodeField, type CodeFieldStatus } from '../code-field';
import { PressJelly } from '../press-jelly';
import { WordCascade } from '../word-cascade';
import { durations, easings, spring, timing } from '../../../lib/kinetik/motion/motion';
import { useReduceMotion } from '../../../lib/kinetik/hooks/use-reduce-motion';
import { useKinetikTheme } from '../../../lib/kinetik/tokens/theme';
import { minTouchTarget } from '../../../lib/kinetik/tokens/tokens';
export type AuthProps = {
/** Send a one-time code to the address. Reject with an Error to show its message. */
onSendCode: (email: string) => Promise<void> | void;
/** Check the code. Reject when it is wrong; the field shakes and clears. */
onVerify: (email: string, code: string) => Promise<void> | void;
/** Called by the button on the final step. */
onDone: () => void;
/** Digits in the code. Defaults to 6. */
codeLength?: number;
/** Seconds before the code can be sent again. Defaults to 30. */
resendAfter?: number;
/** Prefills the address. */
initialEmail?: string;
};
type Step = 'email' | 'code' | 'done';
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Passwordless sign-in in three steps that slide into each other: an email,
* a one-time code with a resend countdown, and a check that pops in once
* you are through.
*/
export function Auth({
onSendCode,
onVerify,
onDone,
codeLength = 6,
resendAfter = 30,
initialEmail = '',
}: AuthProps) {
const { colors, space } = useKinetikTheme();
const insets = useSafeAreaInsets();
const reduced = useReduceMotion();
const [step, setStep] = useState<Step>('email');
const [back, setBack] = useState(false);
const [email, setEmail] = useState(initialEmail);
const enter = reduced
? FadeIn.duration(durations.quick)
: back
? FadeIn.duration(durations.base).easing(easings.standard)
: SlideInRight.duration(durations.slow).easing(easings.standard);
const exit = reduced
? FadeOut.duration(durations.quick)
: back
? FadeOut.duration(durations.quick)
: SlideOutLeft.duration(durations.base).easing(easings.exit);
return (
<KeyboardAvoidingView
style={[styles.fill, { backgroundColor: colors.background }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
style={styles.fill}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
flexGrow: 1,
paddingTop: insets.top + space[6],
paddingBottom: insets.bottom + space[4],
paddingHorizontal: space[6],
}}
>
<Animated.View key={step} entering={enter} exiting={exit} style={styles.fill}>
{step === 'email' ? (
<EmailStep
email={email}
onChange={setEmail}
onSend={async (address) => {
await onSendCode(address);
setEmail(address);
setBack(false);
setStep('code');
}}
/>
) : step === 'code' ? (
<CodeStep
email={email}
length={codeLength}
resendAfter={resendAfter}
onResend={() => onSendCode(email)}
onVerify={(code) => onVerify(email, code)}
onVerified={() => {
Keyboard.dismiss();
setBack(false);
setStep('done');
}}
onChangeEmail={() => {
setBack(true);
setStep('email');
}}
/>
) : (
<DoneStep onDone={onDone} />
)}
</Animated.View>
</ScrollView>
</KeyboardAvoidingView>
);
}
function Heading({ title, body }: { title: string; body: ReactNode }) {
const { colors, space, font } = useKinetikTheme();
return (
<View style={{ gap: space[2] }}>
<WordCascade
text={title}
delay={120}
style={{
color: colors.text,
fontSize: font.size['2xl'],
fontWeight: font.weight.bold,
letterSpacing: font.tracking.tight,
}}
/>
<Text style={{ color: colors.textMuted, fontSize: font.size.md, lineHeight: 22 }}>
{body}
</Text>
</View>
);
}
/** A round badge with an envelope drawn from two views. */
function Envelope() {
const { colors, radius } = useKinetikTheme();
return (
<View
aria-hidden
style={[styles.badge, { backgroundColor: colors.surface, borderColor: colors.border }]}
>
<View style={[styles.letter, { borderColor: colors.text, borderRadius: radius.xs }]}>
<View style={[styles.flap, { borderColor: colors.text }]} />
</View>
</View>
);
}
function EmailStep({
email,
onChange,
onSend,
}: {
email: string;
onChange: (email: string) => void;
onSend: (email: string) => Promise<void>;
}) {
const { colors, space, font, radius } = useKinetikTheme();
const [busy, setBusy] = useState(false);
const [focused, setFocused] = useState(false);
const [error, setError] = useState<string | null>(null);
const send = async () => {
const address = email.trim();
if (!EMAIL.test(address)) {
setError('Enter a valid email address');
return;
}
setError(null);
setBusy(true);
try {
await onSend(address);
} catch (e) {
setError(e instanceof Error && e.message ? e.message : 'We could not send a code.');
setBusy(false);
}
};
return (
<View style={[styles.fill, { gap: space[6] }]}>
<Envelope />
<Heading
title="Sign in with a code"
body="We’ll email you a one-time code. No password to remember."
/>
<View style={{ gap: space[2] }}>
<Text
style={{ color: colors.text, fontSize: font.size.sm, fontWeight: font.weight.medium }}
>
Email
</Text>
<TextInput
value={email}
onChangeText={(text) => {
onChange(text);
if (error) setError(null);
}}
accessibilityLabel="Email"
placeholder="you@example.com"
placeholderTextColor={colors.textSubtle}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
autoComplete="email"
textContentType="emailAddress"
returnKeyType="send"
onSubmitEditing={send}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
editable={!busy}
selectionColor={colors.azure}
cursorColor={colors.azure}
style={[
styles.input,
{
color: colors.text,
fontSize: font.size.md,
backgroundColor: colors.surface,
borderColor: error ? colors.danger : focused ? colors.azure : colors.borderStrong,
borderRadius: radius.md,
paddingHorizontal: space[4],
},
]}
/>
{error ? (
<Text
accessibilityLiveRegion="polite"
style={{ color: colors.danger, fontSize: font.size.xs }}
>
{error}
</Text>
) : null}
</View>
<PressJelly block size="lg" loading={busy} onPress={busy ? undefined : send}>
Send code
</PressJelly>
</View>
);
}
function CodeStep({
email,
length,
resendAfter,
onResend,
onVerify,
onVerified,
onChangeEmail,
}: {
email: string;
length: number;
resendAfter: number;
onResend: () => Promise<void> | void;
onVerify: (code: string) => Promise<void> | void;
onVerified: () => void;
onChangeEmail: () => void;
}) {
const { colors, space, font } = useKinetikTheme();
const [code, setCode] = useState('');
const [status, setStatus] = useState<CodeFieldStatus>('idle');
const [checking, setChecking] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [left, setLeft] = useState(resendAfter);
// One tick per second while the resend link is locked.
useEffect(() => {
if (left <= 0) return;
const id = setTimeout(() => setLeft((s) => s - 1), 1000);
return () => clearTimeout(id);
}, [left]);
const verify = async (value: string) => {
setChecking(true);
setMessage(null);
try {
await onVerify(value);
setStatus('success');
// Let the success wave run along the digits before moving on.
setTimeout(onVerified, 650);
} catch {
setStatus('error');
setMessage('That code didn’t match. Try again.');
setTimeout(() => setCode(''), 450);
} finally {
setChecking(false);
}
};
const resend = async () => {
setMessage(null);
setLeft(resendAfter);
try {
await onResend();
setMessage('A new code is on its way.');
} catch {
setLeft(0);
setMessage('We could not send a new code.');
}
};
const clock = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
return (
<View style={[styles.fill, { gap: space[6] }]}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Change email"
onPress={onChangeEmail}
style={styles.backLink}
>
<Text
style={{
color: colors.textMuted,
fontSize: font.size.sm,
fontWeight: font.weight.medium,
}}
>
‹ Change email
</Text>
</Pressable>
<Heading
title="Check your inbox"
body={
<>
Enter the code we sent to{' '}
<Text style={{ color: colors.text, fontWeight: font.weight.semibold }}>{email}</Text>
</>
}
/>
<View style={{ gap: space[3] }}>
<CodeField
value={code}
onChangeText={(value) => {
setCode(value);
if (status === 'error') setStatus('idle');
}}
onComplete={verify}
length={length}
status={status}
disabled={checking || status === 'success'}
autoFocus
/>
<Text
accessibilityLiveRegion="polite"
style={{
minHeight: 18,
color: status === 'error' ? colors.danger : colors.textMuted,
fontSize: font.size.xs,
}}
>
{checking ? 'Checking…' : (message ?? '')}
</Text>
</View>
{left > 0 ? (
<Text
accessibilityLabel={`You can resend the code in ${left} seconds`}
style={[styles.resend, { color: colors.textSubtle, fontSize: font.size.sm }]}
>
Resend code in {clock}
</Text>
) : (
<Pressable accessibilityRole="button" onPress={resend} style={styles.resend}>
<Text
style={{
color: colors.text,
fontSize: font.size.sm,
fontWeight: font.weight.semibold,
textDecorationLine: 'underline',
}}
>
Resend code
</Text>
</Pressable>
)}
</View>
);
}
/** A check that pops into a filled circle while a ring breathes out once behind it. */
function DoneStep({ onDone }: { onDone: () => void }) {
const { colors, space, font } = useKinetikTheme();
const reduced = useReduceMotion();
const pop = useSharedValue(reduced ? 1 : 0);
const ring = useSharedValue(reduced ? 1 : 0);
useEffect(() => {
if (reduced) return;
pop.set(withDelay(160, withSpring(1, spring('bouncy'))));
ring.set(withDelay(260, withTiming(1, { ...timing('slow'), duration: 900 })));
}, [reduced, pop, ring]);
const badge = useAnimatedStyle(() => ({ transform: [{ scale: pop.get() }] }));
const tick = useAnimatedStyle(() => ({
opacity: Math.min(1, Math.max(0, pop.get() * 2 - 0.8)),
transform: [{ rotate: '-45deg' }, { scale: 0.6 + pop.get() * 0.4 }],
}));
const halo = useAnimatedStyle(() => ({
opacity: reduced ? 0 : (1 - ring.get()) * 0.6,
transform: [{ scale: 1 + ring.get() * 0.9 }],
}));
return (
<View style={[styles.fill, styles.done, { gap: space[6] }]}>
<View style={styles.doneArt}>
<Animated.View style={[styles.halo, { borderColor: colors.success }, halo]} />
<Animated.View style={[styles.doneBadge, { backgroundColor: colors.success }, badge]}>
<Animated.View style={[styles.tick, { borderColor: colors.background }, tick]} />
</Animated.View>
</View>
<View style={{ gap: space[2], alignItems: 'center' }}>
<Text
accessibilityRole="header"
style={{
color: colors.text,
fontSize: font.size['2xl'],
fontWeight: font.weight.bold,
letterSpacing: font.tracking.tight,
}}
>
You’re in
</Text>
<Text
style={{
color: colors.textMuted,
fontSize: font.size.md,
lineHeight: 22,
textAlign: 'center',
}}
>
Your email is verified. Next time it takes a single code.
</Text>
</View>
<PressJelly block size="lg" onPress={onDone} style={styles.doneButton}>
Continue
</PressJelly>
</View>
);
}
const styles = StyleSheet.create({
fill: { flex: 1 },
input: { minHeight: 52, borderWidth: 1 },
badge: {
width: 56,
height: 56,
borderRadius: 28,
borderWidth: StyleSheet.hairlineWidth,
alignItems: 'center',
justifyContent: 'center',
},
letter: { width: 24, height: 18, borderWidth: 1.75, overflow: 'hidden', alignItems: 'center' },
flap: {
width: 16,
height: 16,
marginTop: -10,
borderWidth: 1.75,
transform: [{ rotate: '45deg' }],
},
backLink: { minHeight: minTouchTarget, justifyContent: 'center', alignSelf: 'flex-start' },
resend: {
minHeight: minTouchTarget,
alignSelf: 'center',
justifyContent: 'center',
textAlignVertical: 'center',
},
done: { alignItems: 'center', justifyContent: 'center' },
doneArt: { width: 120, height: 120, alignItems: 'center', justifyContent: 'center' },
halo: { position: 'absolute', width: 96, height: 96, borderRadius: 48, borderWidth: 2 },
doneBadge: {
width: 96,
height: 96,
borderRadius: 48,
alignItems: 'center',
justifyContent: 'center',
},
tick: {
width: 34,
height: 18,
marginTop: -8,
borderLeftWidth: 5,
borderBottomWidth: 5,
borderRadius: 2,
},
doneButton: { alignSelf: 'stretch' },
});