Login Form
An email/password card with inline validation, a password visibility toggle, and loading, failure and success states around a swappable sign-in call.
Live preview
LoginPage.svelte
demo@example.com / password123 — any other pair takes the failure path.Welcome back
Sign in to your account
Don't have an account? Sign up
<script lang="ts">
import {
Alert,
Button,
Card,
Checkbox,
EyeIcon,
EyeOffIcon,
Input,
LockIcon,
Separator
} from '@urbicon-ui/blocks';
let email = $state('');
let password = $state('');
let rememberMe = $state(false);
let showPassword = $state(false);
let loading = $state(false);
let error = $state('');
let success = $state(false);
// An empty field is not a wrong field: these pass while blank, and
// canSubmit gates submission separately.
let emailValid = $derived(email === '' || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email));
let passwordValid = $derived(password === '' || password.length >= 8);
let canSubmit = $derived(email !== '' && password !== '' && emailValid && passwordValid);
async function handleLogin() {
if (!canSubmit) return;
loading = true;
error = '';
// Stand-in for your session call — the demo accepts exactly one pair.
await new Promise((r) => setTimeout(r, 1500));
if (email === 'demo@example.com' && password === 'password123') {
success = true; // a real app navigates here instead
} else {
error = 'Invalid email or password. Try demo@example.com / password123';
}
loading = false;
}
</script>
<!-- Centre it in your page's own layout; the card caps its own width. -->
<div class="w-full max-w-sm">
<Card variant="elevated" padding="lg">
<div class="mb-8 text-center">
<div
class="bg-primary text-text-on-primary rounded-bridge mx-auto mb-4 flex h-12 w-12 items-center justify-center"
>
<LockIcon size={24} />
</div>
<h2 class="text-text-primary text-xl font-bold">Welcome back</h2>
<p class="text-text-tertiary mt-1 text-sm">Sign in to your account</p>
</div>
{#if error}
<div class="mb-6">
<Alert intent="danger" variant="soft" size="sm" dismissible onDismiss={() => (error = '')}>
{error}
</Alert>
</div>
{/if}
{#if success}
<Alert intent="success" variant="soft" size="sm">
Logged in successfully! Redirecting...
</Alert>
{:else}
<form
onsubmit={(e) => {
e.preventDefault();
handleLogin();
}}
class="space-y-5"
>
<Input
label="Email"
type="email"
placeholder="you@example.com"
bind:value={email}
error={!emailValid ? 'Please enter a valid email' : undefined}
required
/>
<div class="relative">
<Input
label="Password"
type={showPassword ? 'text' : 'password'}
placeholder="Min. 8 characters"
bind:value={password}
error={!passwordValid ? 'Password must be at least 8 characters' : undefined}
required
/>
<!-- Icon-only, so the icon must track the state; aria-pressed and
the swapping label tell a screen reader the same thing. -->
<button
type="button"
class="text-text-tertiary hover:text-text-primary absolute top-8 right-3"
aria-label={showPassword ? 'Hide password' : 'Show password'}
aria-pressed={showPassword}
onclick={() => (showPassword = !showPassword)}
>
{#if showPassword}
<EyeOffIcon size={16} />
{:else}
<EyeIcon size={16} />
{/if}
</button>
</div>
<div class="flex items-center justify-between">
<Checkbox label="Remember me" size="sm" bind:checked={rememberMe} />
<a href="#forgot" class="text-primary text-sm hover:underline">Forgot password?</a>
</div>
<Button intent="primary" class="w-full" type="submit" disabled={loading} {loading}>
Sign in
</Button>
</form>
<div class="mt-6">
<Separator />
<p class="text-text-tertiary mt-4 text-left text-sm">
Don't have an account?
<a href="#signup" class="text-primary font-medium hover:underline">Sign up</a>
</p>
</div>
{/if}
</Card>
</div>Two decisions
Errors wait for input
An empty field is not a wrong field: emailValid and passwordValid pass while their field is blank,
so the form does not load red. Submission is gated separately — canSubmit requires both fields filled and valid,
and handleLogin bails without it — so holding the messages
back lets nothing through.
The card fakes the session, not the states
handleLogin is a stand-in: swap its body for your
session call and keep what surrounds it — the Button carries loading, failure lands in a dismissible Alert,
success replaces the form. For sign-in with the logic included — sessions, passkeys, the
forms around them — Passkey Login renders LoginPage from @urbicon-ui/auth.