UI Library
Login split
A sign-in page split in two: email and password with a show/hide toggle, Google and Apple buttons, and a register link on the left; a rotating product showcase on the right that hides on small screens. Validation runs on submit, not on every keystroke.
Authenticationloginsign-inauthformoauthcarousel
Preview
Welcome back
Sign in to Northwind to pick up where you left off.
or
Don't have an account? Register now
apps/site/blocks/components/LoginSplit.tsx
'use client';
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
import { Eye, EyeOff, Lock, Mail, Star, Wallet } from 'lucide-react';
export interface LoginValues {
email: string;
password: string;
}
const SLIDES: { title: ReactNode; body: string; visual: ReactNode }[] = [
{
title: (
<>
Every account,
<br />
one clear balance
</>
),
body: 'Track spending, cards and transfers in one place, with alerts before anything surprises you.',
visual: <CardVisual />,
},
{
title: (
<>
Built for teams
<br />
that ship weekly
</>
),
body: 'Invite your team, set per-card limits, and export to your books in one click.',
visual: <TeamVisual />,
},
];
export function LoginSplit({
productName = 'Northwind',
onSubmit,
onProvider,
}: {
productName?: string;
onSubmit?: (values: LoginValues) => void | Promise<void>;
onProvider?: (provider: 'google' | 'apple') => void;
}) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [reveal, setReveal] = useState(false);
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
const [pending, setPending] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const next: typeof errors = {};
if (!/^\S+@\S+\.\S+$/.test(email)) next.email = 'Enter a valid email address.';
if (!password) next.password = 'Enter your password.';
setErrors(next);
if (next.email || next.password) return;
setPending(true);
try {
await onSubmit?.({ email, password });
} finally {
setPending(false);
}
}
return (
<div className="mx-auto grid w-full max-w-5xl gap-3 rounded-[24px] border border-border bg-background p-3 shadow-xl lg:grid-cols-2">
{/* Form */}
<div className="flex items-center justify-center px-4 py-10 sm:px-10">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center text-center">
<div className="flex h-11 w-11 items-center justify-center rounded-[12px] bg-foreground text-background">
<Wallet className="h-5 w-5" aria-hidden />
</div>
<h1 className="mt-4 text-2xl font-semibold tracking-tight">Welcome back</h1>
<p className="mt-2 text-sm text-muted-foreground">
Sign in to {productName} to pick up where you left off.
</p>
</div>
<form noValidate onSubmit={handleSubmit} className="mt-8 space-y-4">
<Field
id="login-email"
label="Email"
icon={<Mail className="h-4 w-4" aria-hidden />}
error={errors.email}
>
<input
id="login-email"
type="email"
autoComplete="email"
placeholder="you@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={errors.email ? true : undefined}
aria-describedby={errors.email ? 'login-email-error' : undefined}
className={inputClass}
/>
</Field>
<Field
id="login-password"
label="Password"
icon={<Lock className="h-4 w-4" aria-hidden />}
error={errors.password}
trailing={
<button
type="button"
onClick={() => setReveal((r) => !r)}
aria-label={reveal ? 'Hide password' : 'Show password'}
aria-pressed={reveal}
className="absolute right-2 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-[6px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
{reveal ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
}
>
<input
id="login-password"
type={reveal ? 'text' : 'password'}
autoComplete="current-password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-invalid={errors.password ? true : undefined}
aria-describedby={errors.password ? 'login-password-error' : undefined}
className={`${inputClass} pr-10`}
/>
</Field>
<div className="flex justify-end">
<a href="#" className="text-sm font-medium hover:underline">
Forgot password?
</a>
</div>
<button
type="submit"
disabled={pending}
className="h-10 w-full rounded-[8px] bg-primary text-sm font-medium text-primary-foreground shadow-sm transition-opacity hover:opacity-90 disabled:opacity-60"
>
{pending ? 'Signing in…' : 'Log in'}
</button>
</form>
<div className="my-5 flex items-center gap-3 text-xs text-muted-foreground">
<span className="h-px flex-1 bg-border" />
or
<span className="h-px flex-1 bg-border" />
</div>
<div className="space-y-2.5">
<ProviderButton onClick={() => onProvider?.('google')} icon={<GoogleMark />}>
Continue with Google
</ProviderButton>
<ProviderButton onClick={() => onProvider?.('apple')} icon={<AppleMark />}>
Continue with Apple
</ProviderButton>
</div>
<p className="mt-8 text-center text-sm text-muted-foreground">
Don't have an account?{' '}
<a href="#" className="font-medium text-foreground hover:underline">
Register now
</a>
</p>
</div>
</div>
{/* Showcase — decoration, so it is hidden below lg rather than squeezed. */}
<Showcase />
</div>
);
}
const inputClass =
'h-10 w-full rounded-[8px] border border-border bg-background pl-9 pr-3 text-sm outline-none transition-shadow placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring aria-[invalid=true]:border-destructive';
function Field({
id,
label,
icon,
error,
trailing,
children,
}: {
id: string;
label: string;
icon: ReactNode;
error?: string;
trailing?: ReactNode;
children: ReactNode;
}) {
return (
<div>
<label htmlFor={id} className="mb-1.5 block text-sm font-medium">
{label}
</label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
{icon}
</span>
{children}
{trailing}
</div>
{error ? (
<p id={`${id}-error`} className="mt-1.5 text-xs text-destructive">
{error}
</p>
) : null}
</div>
);
}
function ProviderButton({
icon,
onClick,
children,
}: {
icon: ReactNode;
onClick: () => void;
children: string;
}) {
return (
<button
type="button"
onClick={onClick}
className="flex h-10 w-full items-center justify-center gap-2.5 rounded-[8px] border border-border bg-background text-sm font-medium transition-colors hover:bg-accent"
>
{icon}
{children}
</button>
);
}
function Showcase() {
const [slide, setSlide] = useState(0);
const [paused, setPaused] = useState(false);
// Advance every 6 s, but not while hovered, and never for reduced motion.
useEffect(() => {
if (paused || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const t = setInterval(() => setSlide((s) => (s + 1) % SLIDES.length), 6000);
return () => clearInterval(t);
}, [paused]);
const current = SLIDES[slide];
return (
<section
aria-roledescription="carousel"
aria-label="Product highlights"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
className="relative hidden flex-col items-center justify-between overflow-hidden rounded-[16px] bg-muted px-10 pb-8 pt-10 text-center lg:flex"
>
<div key={slide} className="flex w-full flex-1 items-center justify-center animate-in fade-in duration-500">
{current.visual}
</div>
<div key={`t-${slide}`} className="mt-8 animate-in fade-in slide-in-from-bottom-2 duration-500">
<h2 className="text-2xl font-semibold tracking-tight">{current.title}</h2>
<p className="mx-auto mt-3 max-w-sm text-sm text-muted-foreground">{current.body}</p>
</div>
<div className="mt-8 flex items-center gap-1.5 rounded-full bg-background px-3 py-2 shadow-sm">
{SLIDES.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setSlide(i)}
aria-label={`Show slide ${i + 1}`}
aria-current={i === slide}
className={`h-1.5 rounded-full transition-all ${
i === slide ? 'w-6 bg-foreground' : 'w-1.5 bg-muted-foreground/40'
}`}
/>
))}
</div>
</section>
);
}
function CardVisual() {
return (
<div className="relative h-64 w-full max-w-sm">
{/* Review note */}
<div className="absolute left-0 top-0 w-44 -rotate-3 rounded-[12px] border border-border bg-background p-3 text-left shadow-md">
<div className="flex gap-0.5 text-primary">
{Array.from({ length: 5 }, (_, i) => (
<Star key={i} className="h-3 w-3 fill-current" aria-hidden />
))}
</div>
<p className="mt-2 text-[11px] font-medium leading-snug">
“Finally one view of every card. Month-end takes minutes now.”
</p>
<p className="mt-2 text-[10px] text-muted-foreground">Finance lead</p>
</div>
{/* Balance chip */}
<div className="absolute right-0 top-6 w-40 rotate-2 rounded-[12px] border border-border bg-background p-3 text-left shadow-md">
<p className="text-[10px] text-muted-foreground">This month</p>
<p className="mt-1 text-sm font-semibold tabular-nums">$4,281.60</p>
<div className="mt-2 h-1 rounded-full bg-muted">
<div className="h-1 w-2/3 rounded-full bg-primary" />
</div>
</div>
{/* Card — fixed dark in both themes, like a physical card. */}
<div className="absolute bottom-0 left-1/2 w-64 -translate-x-1/2 overflow-hidden rounded-[14px] bg-zinc-900 p-4 text-left text-white shadow-xl ring-1 ring-white/10">
<div aria-hidden className="absolute -right-10 -top-10 h-32 w-32 rounded-full border-[14px] border-primary/70" />
<div className="h-6 w-8 rounded-[4px] bg-white/20" />
<p className="mt-6 text-sm font-medium">
Northwind <span className="ml-2 tabular-nums opacity-70">•••• 3421</span>
</p>
<div className="mt-4 flex items-center justify-between border-t border-white/15 pt-3 text-xs">
<span>Company card</span>
<span className="font-semibold tabular-nums">$7,254.00</span>
</div>
</div>
</div>
);
}
function TeamVisual() {
const rows = [
{ name: 'Design', spent: 62 },
{ name: 'Engineering', spent: 84 },
{ name: 'Marketing', spent: 38 },
];
return (
<div className="w-full max-w-xs rounded-[14px] border border-border bg-background p-4 text-left shadow-md">
<p className="text-sm font-semibold">Card limits</p>
<ul className="mt-4 space-y-3">
{rows.map((r) => (
<li key={r.name}>
<div className="flex justify-between text-xs">
<span>{r.name}</span>
<span className="tabular-nums text-muted-foreground">{r.spent}%</span>
</div>
<div className="mt-1.5 h-1.5 rounded-full bg-muted">
<div className="h-1.5 rounded-full bg-primary" style={{ width: `${r.spent}%` }} />
</div>
</li>
))}
</ul>
</div>
);
}
function GoogleMark() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden>
<path fill="#4285F4" d="M23.5 12.3c0-.8-.1-1.6-.2-2.3H12v4.4h6.5a5.5 5.5 0 0 1-2.4 3.6v3h3.9c2.2-2.1 3.5-5.1 3.5-8.7z" />
<path fill="#34A853" d="M12 24c3.2 0 6-1.1 8-2.9l-3.9-3c-1.1.7-2.5 1.2-4.1 1.2-3.1 0-5.8-2.1-6.7-5H1.3v3.1A12 12 0 0 0 12 24z" />
<path fill="#FBBC05" d="M5.3 14.3a7.2 7.2 0 0 1 0-4.6V6.6h-4a12 12 0 0 0 0 10.8l4-3.1z" />
<path fill="#EA4335" d="M12 4.8c1.8 0 3.3.6 4.6 1.8l3.4-3.4A12 12 0 0 0 1.3 6.6l4 3.1c.9-2.9 3.6-4.9 6.7-4.9z" />
</svg>
);
}
function AppleMark() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-current" aria-hidden>
<path d="M16.4 12.7c0-2.6 2.1-3.8 2.2-3.9a4.8 4.8 0 0 0-3.8-2c-1.6-.2-3.1.9-3.9.9-.8 0-2-.9-3.4-.9a5 5 0 0 0-4.2 2.6c-1.8 3.1-.5 7.7 1.3 10.2.9 1.2 1.9 2.6 3.2 2.6 1.3-.1 1.8-.8 3.3-.8 1.6 0 2 .8 3.4.8 1.4 0 2.3-1.3 3.1-2.5a10 10 0 0 0 1.4-2.9c-.1 0-2.6-1-2.6-4.1zM13.9 5.1c.7-.9 1.2-2 1.1-3.1-1 0-2.2.7-2.9 1.5-.6.7-1.2 1.9-1 3 1.1.1 2.2-.6 2.8-1.4z" />
</svg>
);
}
Dependencies
lucide-react— npm package
Colours come from CSS variables (--background, --primary, --border) using the shadcn/ui token names, so the block adopts your palette rather than importing ours.