How-to

FramingUI Builds an Accessible Login Form

Build a production-ready login form with validation, accessibility, and consistent styling using FramingUI design system.

FramingUI Team5 min read

The login form is the first thing most users interact with. A broken focus ring, a missing error announcement, or a submit button that goes silent during loading — these details cost you user trust before anyone sees your product.

FramingUI's form components handle the accessibility contract automatically: label association, aria-describedby wiring, and error role announcements come from the component structure, not custom code you have to maintain.

Installing Dependencies

pnpm add @framingui/ui @framingui/core @framingui/tokens tailwindcss-animate

Then add react-hook-form and zod for validation:

pnpm add react-hook-form zod @hookform/resolvers

Building the Basic Login Form

The component below wires validation, loading state, and accessible error messages with minimal boilerplate.

'use client';

import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
  Card,
  CardHeader,
  CardTitle,
  CardDescription,
  CardContent,
  CardFooter,
  Button,
  Input,
  Form,
  FormField,
  FormItem,
  FormLabel,
  FormControl,
  FormMessage,
} from '@framingui/ui';

const loginSchema = z.object({
  email: z.string().email('Please enter a valid email'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
});

type LoginFormData = z.infer<typeof loginSchema>;

export function LoginForm() {
  const [isLoading, setIsLoading] = useState(false);

  const form = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
    defaultValues: { email: '', password: '' },
  });

  async function onSubmit(data: LoginFormData) {
    setIsLoading(true);
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        body: JSON.stringify(data),
      });
      if (!response.ok) throw new Error('Invalid credentials');
    } finally {
      setIsLoading(false);
    }
  }

  return (
    <Card className="w-full max-w-md">
      <CardHeader>
        <CardTitle>Sign In</CardTitle>
        <CardDescription>Enter your credentials to access your account</CardDescription>
      </CardHeader>
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)}>
          <CardContent className="space-y-[var(--spacing-4)]">
            <FormField
              control={form.control}
              name="email"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Email</FormLabel>
                  <FormControl>
                    <Input type="email" placeholder="[email protected]" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="password"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Password</FormLabel>
                  <FormControl>
                    <Input type="password" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
          </CardContent>
          <CardFooter className="flex flex-col space-y-[var(--spacing-4)]">
            <Button type="submit" className="w-full" disabled={isLoading}>
              {isLoading ? 'Signing in...' : 'Sign In'}
            </Button>
            <div className="text-sm text-center">
              <a
                href="/forgot-password"
                className="text-[var(--foreground-accent)] hover:underline"
              >
                Forgot your password?
              </a>
            </div>
          </CardFooter>
        </form>
      </Form>
    </Card>
  );
}

A few things worth noting about this implementation. All spacing uses var(--spacing-*) CSS variables, not arbitrary pixel values. The FormMessage component connects to each field's validation state and renders with role="alert" so screen readers announce errors on submission. The submit button disables during the async call to prevent double-submission.

Adding Social Auth and Remember Me

The enhanced version adds OAuth buttons and a persistent session option:

'use client';

import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
  Card,
  CardHeader,
  CardTitle,
  CardDescription,
  CardContent,
  CardFooter,
  Button,
  Input,
  Checkbox,
  Separator,
  Form,
  FormField,
  FormItem,
  FormLabel,
  FormControl,
  FormMessage,
} from '@framingui/ui';
import { Github, Mail } from 'lucide-react';

const loginSchema = z.object({
  email: z.string().email('Please enter a valid email'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  remember: z.boolean().default(false),
});

type LoginFormData = z.infer<typeof loginSchema>;

export function EnhancedLoginForm() {
  const [isLoading, setIsLoading] = useState(false);

  const form = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
    defaultValues: { email: '', password: '', remember: false },
  });

  async function onSubmit(data: LoginFormData) {
    setIsLoading(true);
    try {
      // Call your authentication API here
    } finally {
      setIsLoading(false);
    }
  }

  async function handleSocialLogin(provider: 'github' | 'google') {
    // Initiate OAuth flow
  }

  return (
    <Card className="w-full max-w-md">
      <CardHeader>
        <CardTitle>Welcome Back</CardTitle>
        <CardDescription>Choose your preferred sign in method</CardDescription>
      </CardHeader>
      <CardContent className="space-y-[var(--spacing-4)]">
        <div className="grid grid-cols-2 gap-[var(--spacing-4)]">
          <Button variant="outline" onClick={() => handleSocialLogin('github')} type="button">
            <Github className="mr-[var(--spacing-2)] h-4 w-4" />
            GitHub
          </Button>
          <Button variant="outline" onClick={() => handleSocialLogin('google')} type="button">
            <Mail className="mr-[var(--spacing-2)] h-4 w-4" />
            Google
          </Button>
        </div>

        <div className="relative">
          <div className="absolute inset-0 flex items-center">
            <Separator />
          </div>
          <div className="relative flex justify-center text-xs uppercase">
            <span className="bg-[var(--background-page)] px-[var(--spacing-2)] text-[var(--foreground-secondary)]">
              Or continue with
            </span>
          </div>
        </div>

        <Form {...form}>
          <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-[var(--spacing-4)]">
            <FormField
              control={form.control}
              name="email"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Email</FormLabel>
                  <FormControl>
                    <Input type="email" placeholder="[email protected]" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="password"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Password</FormLabel>
                  <FormControl>
                    <Input type="password" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="remember"
              render={({ field }) => (
                <FormItem className="flex flex-row items-start space-x-[var(--spacing-3)] space-y-0">
                  <FormControl>
                    <Checkbox checked={field.value} onCheckedChange={field.onChange} />
                  </FormControl>
                  <div className="space-y-[var(--spacing-1)] leading-none">
                    <FormLabel>Remember me</FormLabel>
                  </div>
                </FormItem>
              )}
            />
            <Button type="submit" className="w-full" disabled={isLoading}>
              {isLoading ? 'Signing in...' : 'Sign In'}
            </Button>
          </form>
        </Form>
      </CardContent>
      <CardFooter className="flex justify-center">
        <p className="text-sm text-[var(--foreground-secondary)]">
          Don't have an account?{' '}
          <a href="/signup" className="text-[var(--foreground-accent)] hover:underline">
            Sign up
          </a>
        </p>
      </CardFooter>
    </Card>
  );
}

How Accessibility Works Under the Hood

FramingUI's Form components build the full ARIA relationship automatically. When you write this:

<FormLabel>Email</FormLabel>
<FormControl>
  <Input type="email" {...field} />
</FormControl>
<FormMessage />

The rendered HTML connects every piece:

<label for="email-form-item">Email</label>
<input
  id="email-form-item"
  aria-describedby="email-form-item-message"
/>
<p id="email-form-item-message" role="alert">
  Please enter a valid email
</p>

The role="alert" on the error message ensures screen readers announce validation failures on submission without requiring any additional configuration.

Handling API Errors

Validation schema errors display automatically. Server-side errors need a separate state:

const [apiError, setApiError] = useState('');

async function onSubmit(data: LoginFormData) {
  setIsLoading(true);
  setApiError('');
  try {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      body: JSON.stringify(data),
    });
    if (!response.ok) throw new Error('Invalid credentials');
  } catch (error) {
    setApiError(error instanceof Error ? error.message : 'Something went wrong');
  } finally {
    setIsLoading(false);
  }
}

// In JSX, above the submit button:
{apiError && (
  <p className="text-sm text-[var(--border-error)]" role="alert">
    {apiError}
  </p>
)}

Centering the form on a page is one layout wrapper:

<div className="flex min-h-screen items-center justify-center p-[var(--spacing-4)]">
  <EnhancedLoginForm />
</div>

The form structure above is a starting point. The real work is connecting onSubmit to your auth provider — NextAuth.js, Supabase, Firebase, or a custom API. The component handles the UI contract; the auth logic is yours to wire.

Ready to build with FramingUI?

Build consistent UI with AI-ready design tokens. No more hallucinated colors or spacing.

Try FramingUI
Share

Related Posts