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.