Skip to content

Magic link

A one-field sign-in whose success state names the address and offers a correction path.

Authenticationstarterauthpasswordlessemail

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

This exact file renders the preview above.

'use client'

import { Mail } from 'lucide-react'
import { Alert } from '@/components/ui/alert'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, required } from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Magic link sign-in
 *
 * A single-field flow whose success state is the whole product. The
 * confirmation names the address it was sent to and offers a correction path,
 * because "check your email" is useless if you mistyped the address.
 */
export default function MagicLinkForm() {
  const form = useDemoForm({
    schema: { email: { validators: [required('Email'), email()] } },
    latency: 700,
  })

  return (
    <FormShell
      title="Sign in without a password"
      description="We will email you a link that signs you in on this device."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Email me a link"
      submittingLabel="Sending link"
      onReset={form.reset}
      successTitle="Link sent"
      successBody={
        <p>
          A sign-in link would be on its way to{' '}
          <span className="font-medium text-ink">{form.values.email}</span>. This is a template
          demo. No submission was sent. Use “Reset the form” to correct the address.
        </p>
      }
      width="sm"
    >
      <Field name="email" label="Email" required error={form.error('email')}>
        {(field) => (
          <Input
            {...field}
            type="email"
            autoComplete="email"
            placeholder="you@company.com"
            leading={<Mail className="size-4" />}
            {...form.field('email')}
          />
        )}
      </Field>

      <Alert tone="info" title="Links expire quickly">
        A magic link is valid for 10 minutes and can only be used once.
      </Alert>
    </FormShell>
  )
}

components/blocks/forms/magic-link.tsx

'use client'

import { Mail } from 'lucide-react'
import { Alert } from '@/components/ui/alert'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, required } from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Magic link sign-in
 *
 * A single-field flow whose success state is the whole product. The
 * confirmation names the address it was sent to and offers a correction path,
 * because "check your email" is useless if you mistyped the address.
 */
export default function MagicLinkForm() {
  const form = useDemoForm({
    schema: { email: { validators: [required('Email'), email()] } },
    latency: 700,
  })

  return (
    <FormShell
      title="Sign in without a password"
      description="We will email you a link that signs you in on this device."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Email me a link"
      submittingLabel="Sending link"
      onReset={form.reset}
      successTitle="Link sent"
      successBody={
        <p>
          A sign-in link would be on its way to{' '}
          <span className="font-medium text-ink">{form.values.email}</span>. This is a template
          demo. No submission was sent. Use “Reset the form” to correct the address.
        </p>
      }
      width="sm"
    >
      <Field name="email" label="Email" required error={form.error('email')}>
        {(field) => (
          <Input
            {...field}
            type="email"
            autoComplete="email"
            placeholder="you@company.com"
            leading={<Mail className="size-4" />}
            {...form.field('email')}
          />
        )}
      </Field>

      <Alert tone="info" title="Links expire quickly">
        A magic link is valid for 10 minutes and can only be used once.
      </Alert>
    </FormShell>
  )
}

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

The success state is the whole product here. “Check your email” is useless if you mistyped the address, so the confirmation echoes it back and offers a way to start again.

  • Link validity is stated in the form, before submission.
  • One field means one decision — resist adding a password fallback on the same screen.

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
  • Loading
  • Success with address echo

Accessibility

Address echo
The submitted address is repeated in the confirmation so a typo is visible.
Single action
Nothing competes with the primary button.

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.

All forms