Kinetik UI

Login

.md

An email and password sign-in that settles in row by row, with inline errors and provider buttons.

v1.0.0Live on webreact-native-reanimatedreact-native-safe-area-contextreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add login

The 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/login.json

Install manually

  1. Install dependencies
    npx expo install react-native-reanimated react-native-safe-area-context react-native-worklets
  2. Copy the source files
    • components/kinetik/screens/login.tsx
    View source files

Usage

import { Login } from '@/components/kinetik/screens/login';

<Login
  onLogin={async ({ email, password }) => {
    const { error } = await auth.signIn({ email, password });
    if (error) throw new Error('Email or password is incorrect.');
    router.replace('/home');
  }}
  onForgotPassword={() => router.push('/reset')}
  onSignUp={() => router.push('/sign-up')}
  providers={[
    {
      id: 'apple',
      label: 'Continue with Apple',
      icon: <AppleLogo />,
      onPress: signInWithApple,
    },
    {
      id: 'google',
      label: 'Continue with Google',
      icon: <GoogleLogo />,
      onPress: signInWithGoogle,
    },
  ]}
/>;

onLogin runs only when the email looks valid and the password is not empty. Return a promise to show a spinner; throw an Error and its message appears above the button.

When to use

  • Apps with email and password accounts, optionally next to Apple or Google sign-in.

When not to use

  • Passwordless sign-in: use the Auth screen, which sends a one-time code.
  • Sign-up: it needs different copy, password rules and consent; build it from the same parts.

Notes

  • Provider buttons take your own icons, so you can follow each provider's branding rules.
  • The form sits in a KeyboardAvoidingView and scrolls, so the button stays reachable with the keyboard open.
  • Pass logo to replace the mark at the top with your app icon.

Props

LoginProps

PropTypeDefaultDescription
onLoginrequired(credentials: LoginCredentials) => Promise<void> | void—Sign in. Return a promise for a spinner; reject with an Error to show its message.
onForgotPassword() => void—
onSignUp() => void—Shows "Don't have an account? Sign up" at the bottom.
providersreadonly LoginProvider[][]Buttons under the form, e.g. Apple and Google.
titlestring'Welcome back'
subtitlestring'Log in to pick up where you left off.'
logoReactNode—Replaces the mark at the top.

Performance and accessibility

Performance

Budget
The entrance is a one-off layout animation per block on the UI thread; after that the screen is static apart from the button's press spring.
Measured

Pixel 9 emulator, API 35, release build · interaction median 1.06× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 157 MB app memory. Most janky frames are the software keyboard opening and closing on the emulator; scrolling the form stays at the baseline.

Real-device measurement pending.

Accessibility

Reduced motion
Blocks appear in place, the title appears without rising and the password eye switches without animating (Reveal Field).
Screen readers
Fields are named Email and Password; their errors are read as they appear. A failed sign-in is an alert. The button reports when it is busy.
Touch target
Fields are 52 pt tall; the button is 56 pt; 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/login.tsx
import { useState, type ReactNode } from 'react';
import {
  KeyboardAvoidingView,
  Platform,
  Pressable,
  ScrollView,
  StyleSheet,
  Text,
  TextInput,
  View,
} from 'react-native';
import Animated, { FadeIn, FadeInDown } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { PressJelly } from '../press-jelly';
import { RevealField } from '../reveal-field';
import { WordCascade } from '../word-cascade';
import { durations, easings } 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 LoginProvider = {
  id: string;
  /** Button label, e.g. "Continue with Apple". */
  label: string;
  /** Icon before the label, in the text colour. */
  icon?: ReactNode;
  onPress: () => void;
};

export type LoginCredentials = { email: string; password: string };

export type LoginProps = {
  /** Sign in. Return a promise for a spinner; reject with an Error to show its message. */
  onLogin: (credentials: LoginCredentials) => Promise<void> | void;
  onForgotPassword?: () => void;
  /** Shows "Don't have an account? Sign up" at the bottom. */
  onSignUp?: () => void;
  /** Buttons under the form, e.g. Apple and Google. */
  providers?: readonly LoginProvider[];
  title?: string;
  subtitle?: string;
  /** Replaces the mark at the top. */
  logo?: ReactNode;
};

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

/**
 * An email and password sign-in: the title rises in, the form settles into
 * place row by row, the button holds a spinner while you wait and a failed
 * attempt explains itself above it.
 */
export function Login({
  onLogin,
  onForgotPassword,
  onSignUp,
  providers = [],
  title = 'Welcome back',
  subtitle = 'Log in to pick up where you left off.',
  logo,
}: LoginProps) {
  const { colors, space, font, radius } = useKinetikTheme();
  const insets = useSafeAreaInsets();
  const reduced = useReduceMotion();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [tried, setTried] = useState(false);
  const [state, setState] = useState<'idle' | 'busy' | 'done'>('idle');
  const [failure, setFailure] = useState<string | null>(null);
  const [focused, setFocused] = useState(false);

  const emailError = tried && !EMAIL.test(email.trim()) ? 'Enter a valid email address' : undefined;
  const passwordError = tried && password.length === 0 ? 'Enter your password' : undefined;

  const submit = async () => {
    setTried(true);
    setFailure(null);
    if (!EMAIL.test(email.trim()) || password.length === 0) return;
    setState('busy');
    try {
      await onLogin({ email: email.trim(), password });
      setState('done');
    } catch (e) {
      setState('idle');
      setFailure(e instanceof Error && e.message ? e.message : 'Those details did not match.');
    }
  };

  // Each block settles in a beat after the one above it.
  const enter = (step: number) =>
    reduced
      ? undefined
      : FadeInDown.delay(120 + step * 70)
          .duration(durations.slow)
          .easing(easings.standard);

  const label = (text: string) => (
    <Text style={{ color: colors.text, fontSize: font.size.sm, fontWeight: font.weight.medium }}>
      {text}
    </Text>
  );

  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[10],
          paddingBottom: insets.bottom + space[4],
          paddingHorizontal: space[6],
          gap: space[6],
        }}
      >
        <View style={{ gap: space[4] }}>
          <Animated.View entering={enter(0)}>
            {logo ?? (
              <View
                style={[styles.mark, { backgroundColor: colors.text, borderRadius: radius.md }]}
              >
                <View style={[styles.markDot, { backgroundColor: colors.background }]} />
              </View>
            )}
          </Animated.View>
          <View style={{ gap: space[2] }}>
            <WordCascade
              text={title}
              delay={reduced ? 0 : 80}
              style={{
                color: colors.text,
                fontSize: font.size['2xl'],
                fontWeight: font.weight.bold,
                letterSpacing: font.tracking.tight,
              }}
            />
            <Animated.Text
              entering={enter(1)}
              style={{ color: colors.textMuted, fontSize: font.size.md, lineHeight: 22 }}
            >
              {subtitle}
            </Animated.Text>
          </View>
        </View>

        <Animated.View entering={enter(2)} style={{ gap: space[4] }}>
          <View style={{ gap: space[2] }}>
            {label('Email')}
            <TextInput
              value={email}
              onChangeText={setEmail}
              accessibilityLabel="Email"
              placeholder="you@example.com"
              placeholderTextColor={colors.textSubtle}
              keyboardType="email-address"
              autoCapitalize="none"
              autoCorrect={false}
              autoComplete="email"
              textContentType="emailAddress"
              returnKeyType="next"
              onFocus={() => setFocused(true)}
              onBlur={() => setFocused(false)}
              editable={state !== 'busy'}
              selectionColor={colors.azure}
              cursorColor={colors.azure}
              style={[
                styles.input,
                {
                  color: colors.text,
                  fontSize: font.size.md,
                  backgroundColor: colors.surface,
                  borderColor: emailError
                    ? colors.danger
                    : focused
                      ? colors.azure
                      : colors.borderStrong,
                  borderRadius: radius.md,
                  paddingHorizontal: space[4],
                },
              ]}
            />
            {emailError ? (
              <Animated.Text
                entering={reduced ? undefined : FadeIn.duration(durations.base)}
                accessibilityLiveRegion="polite"
                style={{ color: colors.danger, fontSize: font.size.xs }}
              >
                {emailError}
              </Animated.Text>
            ) : null}
          </View>
          <View style={{ gap: space[2] }}>
            <RevealField
              value={password}
              onChangeText={setPassword}
              editable={state !== 'busy'}
              autoComplete="current-password"
              textContentType="password"
              onSubmitEditing={submit}
              returnKeyType="go"
            />
            {passwordError ? (
              <Animated.Text
                entering={reduced ? undefined : FadeIn.duration(durations.base)}
                accessibilityLiveRegion="polite"
                style={{ color: colors.danger, fontSize: font.size.xs }}
              >
                {passwordError}
              </Animated.Text>
            ) : null}
          </View>
          {onForgotPassword ? (
            <Pressable
              accessibilityRole="link"
              onPress={onForgotPassword}
              style={[styles.link, styles.end]}
            >
              <Text
                style={{
                  color: colors.textMuted,
                  fontSize: font.size.sm,
                  fontWeight: font.weight.medium,
                }}
              >
                Forgot password?
              </Text>
            </Pressable>
          ) : null}
        </Animated.View>

        <Animated.View entering={enter(3)} style={{ gap: space[3] }}>
          {failure ? (
            <Animated.View
              entering={reduced ? undefined : FadeInDown.duration(durations.base)}
              accessible
              accessibilityRole="alert"
              accessibilityLiveRegion="assertive"
              style={[
                styles.alert,
                {
                  backgroundColor: colors.surface,
                  borderColor: colors.danger,
                  borderRadius: radius.md,
                  padding: space[3],
                  gap: space[2],
                },
              ]}
            >
              <View style={[styles.alertDot, { backgroundColor: colors.danger }]} />
              <Text style={[styles.fill, { color: colors.text, fontSize: font.size.sm }]}>
                {failure}
              </Text>
            </Animated.View>
          ) : null}
          <PressJelly
            block
            size="lg"
            variant={state === 'done' ? 'surface' : 'ember'}
            loading={state === 'busy'}
            onPress={state === 'idle' ? submit : undefined}
          >
            {state === 'done' ? 'Logged in' : 'Log in'}
          </PressJelly>
        </Animated.View>

        {providers.length > 0 ? (
          <Animated.View entering={enter(4)} style={{ gap: space[3] }}>
            <View style={[styles.row, { gap: space[3] }]}>
              <View style={[styles.rule, { backgroundColor: colors.border }]} />
              <Text style={{ color: colors.textSubtle, fontSize: font.size.xs }}>or</Text>
              <View style={[styles.rule, { backgroundColor: colors.border }]} />
            </View>
            {providers.map((p) => (
              <PressJelly
                key={p.id}
                block
                variant="surface"
                leading={p.icon}
                disabled={state === 'busy'}
                onPress={p.onPress}
              >
                {p.label}
              </PressJelly>
            ))}
          </Animated.View>
        ) : null}

        {onSignUp ? (
          <Animated.View entering={enter(5)} style={[styles.row, styles.footer]}>
            <Text style={{ color: colors.textMuted, fontSize: font.size.sm }}>
              Don’t have an account?
            </Text>
            <Pressable accessibilityRole="link" onPress={onSignUp} style={styles.link}>
              <Text
                style={{
                  color: colors.text,
                  fontSize: font.size.sm,
                  fontWeight: font.weight.semibold,
                  textDecorationLine: 'underline',
                }}
              >
                Sign up
              </Text>
            </Pressable>
          </Animated.View>
        ) : null}
      </ScrollView>
    </KeyboardAvoidingView>
  );
}

const styles = StyleSheet.create({
  fill: { flex: 1 },
  mark: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' },
  markDot: { width: 16, height: 16, borderRadius: 8, marginLeft: 10 },
  input: { minHeight: 52, borderWidth: 1 },
  link: { minHeight: minTouchTarget, justifyContent: 'center', paddingHorizontal: 4 },
  end: { alignSelf: 'flex-end', marginTop: -8 },
  alert: { flexDirection: 'row', alignItems: 'center', borderWidth: 1 },
  alertDot: { width: 8, height: 8, borderRadius: 4 },
  row: { flexDirection: 'row', alignItems: 'center' },
  rule: { flex: 1, height: StyleSheet.hairlineWidth },
  footer: { justifyContent: 'center', marginTop: 'auto', gap: 2 },
});