Login
Email and password with a form-level rejection that does not reveal which half was wrong.
Live preview
Source
This exact file renders the preview above.
'use client'
import Link from 'next/link'
import { Checkbox } from '@/components/ui/choice'
import { Divider } from '@/components/ui/layout'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, required } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Login form
*
* Includes the failure path most login demos omit: a server-style rejection
* that is not attributable to a single field. Signing in with the wrong
* password produces a form-level error rather than marking the email invalid,
* because telling an attacker which half was wrong is a security decision, not
* a UX one.
*
* Demo credentials: any email plus the password `foundry`.
*/
export default function LoginForm() {
const form = useDemoForm({
schema: {
email: { validators: [required('Email'), email()] },
password: { validators: [required('Password')] },
remember: { initial: 'true' },
},
simulateServerError: (values) =>
values.password === 'foundry'
? null
: 'That email and password combination was not recognised.',
})
return (
<FormShell
title="Sign in"
description="Use any email with the password “foundry” to see the success state."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Sign in"
submittingLabel="Signing in"
onReset={form.reset}
successTitle="Signed in"
successBody={
<p>This is a template demo. No submission was sent and no session was created.</p>
}
width="sm"
footer={
<>
Not registered?{' '}
<Link
href="/forms/register"
className="font-medium text-accent underline underline-offset-4"
>
Create an account
</Link>
</>
}
>
<Field name="email" label="Email" required error={form.error('email')}>
{(field) => (
<Input
{...field}
type="email"
autoComplete="email"
placeholder="you@company.com"
{...form.field('email')}
/>
)}
</Field>
<Field
name="password"
label="Password"
required
error={form.error('password')}
hint="Demo password: foundry"
>
{(field) => (
<Input
{...field}
type="password"
autoComplete="current-password"
{...form.field('password')}
/>
)}
</Field>
<div className="flex flex-wrap items-center justify-between gap-2">
<Checkbox
id="login-remember"
name="remember"
label="Keep me signed in"
checked={form.values.remember === 'true'}
onChange={(event) => form.setValue('remember', String(event.target.checked))}
/>
<Link
href="/forms/forgot-password"
className="text-sm text-accent underline underline-offset-4"
>
Forgot password?
</Link>
</div>
<Divider label="or" />
<div className="flex flex-col gap-2">
<Button type="button" variant="outline" block>
Continue with SSO
</Button>
<Button type="button" variant="outline" block>
Continue with a magic link
</Button>
</div>
</FormShell>
)
}
components/blocks/forms/login.tsx
'use client'
import Link from 'next/link'
import { Checkbox } from '@/components/ui/choice'
import { Divider } from '@/components/ui/layout'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, required } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Login form
*
* Includes the failure path most login demos omit: a server-style rejection
* that is not attributable to a single field. Signing in with the wrong
* password produces a form-level error rather than marking the email invalid,
* because telling an attacker which half was wrong is a security decision, not
* a UX one.
*
* Demo credentials: any email plus the password `foundry`.
*/
export default function LoginForm() {
const form = useDemoForm({
schema: {
email: { validators: [required('Email'), email()] },
password: { validators: [required('Password')] },
remember: { initial: 'true' },
},
simulateServerError: (values) =>
values.password === 'foundry'
? null
: 'That email and password combination was not recognised.',
})
return (
<FormShell
title="Sign in"
description="Use any email with the password “foundry” to see the success state."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Sign in"
submittingLabel="Signing in"
onReset={form.reset}
successTitle="Signed in"
successBody={
<p>This is a template demo. No submission was sent and no session was created.</p>
}
width="sm"
footer={
<>
Not registered?{' '}
<Link
href="/forms/register"
className="font-medium text-accent underline underline-offset-4"
>
Create an account
</Link>
</>
}
>
<Field name="email" label="Email" required error={form.error('email')}>
{(field) => (
<Input
{...field}
type="email"
autoComplete="email"
placeholder="you@company.com"
{...form.field('email')}
/>
)}
</Field>
<Field
name="password"
label="Password"
required
error={form.error('password')}
hint="Demo password: foundry"
>
{(field) => (
<Input
{...field}
type="password"
autoComplete="current-password"
{...form.field('password')}
/>
)}
</Field>
<div className="flex flex-wrap items-center justify-between gap-2">
<Checkbox
id="login-remember"
name="remember"
label="Keep me signed in"
checked={form.values.remember === 'true'}
onChange={(event) => form.setValue('remember', String(event.target.checked))}
/>
<Link
href="/forms/forgot-password"
className="text-sm text-accent underline underline-offset-4"
>
Forgot password?
</Link>
</div>
<Divider label="or" />
<div className="flex flex-col gap-2">
<Button type="button" variant="outline" block>
Continue with SSO
</Button>
<Button type="button" variant="outline" block>
Continue with a magic link
</Button>
</div>
</FormShell>
)
}
components/ui/field.tsx
import type { ReactNode } from 'react'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { cn } from '@/lib/cn'
/**
* Field
*
* The accessibility contract for every form control in Foundry lives here, in
* one place, rather than being re-implemented per input.
*
* Ids are derived deterministically from `name` instead of `useId`, for two
* reasons: the component stays renderable from a Server Component (no hooks),
* and the markup is byte-identical between server and client, so a form works
* before hydration — see `/docs/accessibility`.
*
* The render prop hands back the exact wiring a control needs, which makes it
* impossible to forget `aria-describedby` on a field that has help text.
*/
export interface FieldRenderProps {
id: string
name: string
'aria-describedby': string | undefined
'aria-invalid': true | undefined
'aria-required': true | undefined
required: boolean
invalid: boolean
}
export interface FieldProps {
name: string
label: ReactNode
hint?: ReactNode
/** Validation failure. Presence flips the field into its invalid state. */
error?: string | null
/** Confirmation message shown once a value has validated. */
success?: string | null
required?: boolean
/** Marks the field "Optional" instead of marking required fields. */
showOptional?: boolean
/** Visually hides the label while keeping it available to screen readers. */
hideLabel?: boolean
className?: string
/** Disambiguates ids when the same field name appears twice on one page. */
idPrefix?: string
children: (props: FieldRenderProps) => ReactNode
}
export function Field({
name,
label,
hint,
error,
success,
required = false,
showOptional = false,
hideLabel = false,
className,
idPrefix = 'field',
children,
}: FieldProps) {
const id = `${idPrefix}-${name}`
const hintId = hint ? `${id}-hint` : undefined
const errorId = error ? `${id}-error` : undefined
const successId = success && !error ? `${id}-success` : undefined
const describedBy = [hintId, errorId, successId].filter(Boolean).join(' ') || undefined
return (
<div className={cn('flex flex-col gap-1.5', className)}>
<label
htmlFor={id}
className={cn(
'flex items-baseline gap-1.5 text-sm font-medium text-ink',
hideLabel && 'sr-only',
)}
>
<span>{label}</span>
{required && !showOptional ? (
<span className="text-danger" aria-hidden="true">
*
</span>
) : null}
{showOptional && !required ? (
<span className="text-xs font-normal text-ink-subtle">Optional</span>
) : null}
</label>
{hint ? (
<p id={hintId} className="text-xs leading-normal text-ink-muted">
{hint}
</p>
) : null}
{children({
id,
name,
'aria-describedby': describedBy,
'aria-invalid': error ? true : undefined,
'aria-required': required || undefined,
required,
invalid: Boolean(error),
})}
{error ? (
<p id={errorId} className="flex items-start gap-1.5 text-xs font-medium text-danger">
<AlertCircle className="mt-px size-3.5 shrink-0" aria-hidden="true" />
<span>{error}</span>
</p>
) : null}
{success && !error ? (
<p id={successId} className="flex items-start gap-1.5 text-xs font-medium text-success">
<CheckCircle2 className="mt-px size-3.5 shrink-0" aria-hidden="true" />
<span>{success}</span>
</p>
) : null}
</div>
)
}
/** Groups related controls (radios, checkboxes) with a shared legend. */
export interface FieldsetProps {
legend: ReactNode
hint?: ReactNode
error?: string | null
name: string
required?: boolean
className?: string
children: ReactNode
idPrefix?: string
}
export function Fieldset({
legend,
hint,
error,
name,
required = false,
className,
children,
idPrefix = 'group',
}: FieldsetProps) {
const id = `${idPrefix}-${name}`
const hintId = hint ? `${id}-hint` : undefined
const errorId = error ? `${id}-error` : undefined
const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined
return (
<fieldset
className={cn('flex min-w-0 flex-col gap-2', className)}
aria-describedby={describedBy}
aria-invalid={error ? true : undefined}
aria-required={required || undefined}
>
<legend className="flex items-baseline gap-1.5 text-sm font-medium text-ink">
<span>{legend}</span>
{required ? (
<span className="text-danger" aria-hidden="true">
*
</span>
) : null}
</legend>
{hint ? (
<p id={hintId} className="text-xs text-ink-muted">
{hint}
</p>
) : null}
{children}
{error ? (
<p id={errorId} className="flex items-start gap-1.5 text-xs font-medium text-danger">
<AlertCircle className="mt-px size-3.5 shrink-0" aria-hidden="true" />
<span>{error}</span>
</p>
) : null}
</fieldset>
)
}
hooks/use-demo-form.ts
'use client'
import { useCallback, useMemo, useRef, useState, type FormEvent } from 'react'
import { runValidators, type Validator } from '@/lib/validation'
export type FormStatus = 'idle' | 'submitting' | 'success' | 'error'
export interface FieldSchema {
initial?: string
validators?: Validator[]
}
export interface UseDemoFormOptions<Schema extends Record<string, FieldSchema>> {
schema: Schema
/**
* Simulated latency, in milliseconds, so the loading state is observable.
* Nothing is sent anywhere.
*/
latency?: number
/** Return a message to force a server-style failure for demonstration. */
simulateServerError?: (values: Record<string, string>) => string | null
onSuccess?: (values: Record<string, string>) => void
}
/**
* useDemoForm
*
* The shared behaviour behind all 28 Foundry form flows:
*
* - validate on submit, then re-validate that field on every change
* (validating on first keystroke punishes people mid-word)
* - move focus to the first invalid control, so a keyboard or screen-reader
* user is taken to the problem rather than told one exists
* - announce the failure count in a polite live region
* - expose a server-style error path that is not attached to any one field
*
* No request is ever made. `latency` exists purely so the submitting state is
* long enough to see.
*/
export function useDemoForm<Schema extends Record<string, FieldSchema>>({
schema,
latency = 900,
simulateServerError,
onSuccess,
}: UseDemoFormOptions<Schema>) {
const initialValues = useMemo(() => {
const entries = Object.entries(schema).map(([key, field]) => [key, field.initial ?? ''])
return Object.fromEntries(entries) as Record<string, string>
}, [schema])
const [values, setValues] = useState<Record<string, string>>(initialValues)
const [errors, setErrors] = useState<Record<string, string | null>>({})
const [status, setStatus] = useState<FormStatus>('idle')
const [serverError, setServerError] = useState<string | null>(null)
const [submitted, setSubmitted] = useState(false)
const formRef = useRef<HTMLFormElement>(null)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const setValue = useCallback(
(name: string, value: string) => {
setValues((current) => {
const next = { ...current, [name]: value }
// Only re-validate a field the user has already been told about.
setErrors((currentErrors) => {
if (currentErrors[name] === undefined) return currentErrors
const validators = schema[name]?.validators ?? []
return { ...currentErrors, [name]: runValidators(value, next, validators) }
})
return next
})
setServerError(null)
},
[schema],
)
const validateAll = useCallback(
(currentValues: Record<string, string>) => {
const next: Record<string, string | null> = {}
for (const [name, field] of Object.entries(schema)) {
next[name] = runValidators(currentValues[name] ?? '', currentValues, field.validators ?? [])
}
return next
},
[schema],
)
const focusFirstInvalid = useCallback((nextErrors: Record<string, string | null>) => {
const firstInvalid = Object.keys(nextErrors).find((name) => nextErrors[name])
if (!firstInvalid || !formRef.current) return
const control = formRef.current.querySelector<HTMLElement>(
`[name="${firstInvalid}"], #field-${firstInvalid}, [data-field="${firstInvalid}"]`,
)
control?.focus({ preventScroll: false })
}, [])
const handleSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (status === 'submitting') return
const nextErrors = validateAll(values)
setErrors(nextErrors)
setSubmitted(true)
const invalidCount = Object.values(nextErrors).filter(Boolean).length
if (invalidCount > 0) {
focusFirstInvalid(nextErrors)
setStatus('idle')
return
}
setStatus('submitting')
setServerError(null)
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(() => {
const failure = simulateServerError?.(values) ?? null
if (failure) {
setStatus('error')
setServerError(failure)
return
}
setStatus('success')
onSuccess?.(values)
}, latency)
},
[values, status, validateAll, focusFirstInvalid, simulateServerError, onSuccess, latency],
)
const reset = useCallback(() => {
if (timer.current) clearTimeout(timer.current)
setValues(initialValues)
setErrors({})
setStatus('idle')
setServerError(null)
setSubmitted(false)
}, [initialValues])
const invalidCount = Object.values(errors).filter(Boolean).length
return {
formRef,
values,
errors,
status,
serverError,
submitted,
invalidCount,
setValue,
handleSubmit,
reset,
/** Convenience: props for a controlled text control. */
field: (name: string) => ({
value: values[name] ?? '',
onChange: (event: { target: { value: string } }) => setValue(name, event.target.value),
}),
error: (name: string) => errors[name] ?? null,
isSubmitting: status === 'submitting',
isSuccess: status === 'success',
}
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
Includes the failure path most login demos omit. Signing in with the wrong password produces a form-level error rather than marking the email invalid — telling an attacker which half was wrong is a security decision, not a UX one. Demo password: foundry.
- The error is announced through an Alert, not attached to a field.
- Alternative sign-in methods sit below a labelled divider so they read as alternatives, not extra steps.
- “Keep me signed in” is a checkbox, not a switch: it applies on submit, not immediately.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- Idle
- Invalid email
- Server-style rejection
- Loading
- Success
- SSO and magic-link alternatives
Accessibility
- Error summary
- A polite live region reports how many fields need attention after a failed submit.
- Autocomplete
- `email` and `current-password` tokens let password managers fill the form.
- Focus
- Failed validation moves focus to the first invalid control.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.
Related
All formsRegister
Account creation with a live password-strength meter that is announced as words, not bars.
intermediateFeatured5 variantsForgot password
A reset request whose success message deliberately does not confirm whether the account exists.
starter4 variantsMagic link
A one-field sign-in whose success state names the address and offers a correction path.
starter4 variants