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&apos;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">
          &ldquo;Finally one view of every card. Month-end takes minutes now.&rdquo;
        </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.