Sign in, sign up, one-time codes, passkeys, account settings and the form toolkit.
Complete flows with the hard parts done: validation timing, error wording, password rules from NIST, and fields that work with password managers.
7 parts
The whole front door in one component: email first, password or passkey, GitHub or Google, sign-up with a code, and recovery.
import { useState } from 'react'import { DEMO_ACCOUNT, SignInFlow, type AuthLink } from '@rchursin/product/forms'import { useAnnounce } from '@rchursin/product/lib/announce'import { Button, useToast } from '@rchursin/product/ui'
/** Sample footer links: '#' addresses, so nothing here points at pages this site doesn't have */const demoLinks: AuthLink[] = [ { label: 'Terms', href: '#terms' }, { label: 'Privacy', href: '#privacy' },]
/** * The whole front door on the demo API: nothing leaves the page. Any email works; the password * and the code are in the strip, and the flow shows them again on the steps that need them. */export default function SignInFlowDemo() { const [run, setRun] = useState(0) const announce = useAnnounce() const toast = useToast()
The frame for every sign-in page: a dark brand panel when there is room, one calm form column, the logo on top and legal links below.
import { useRef, useState, type FormEvent, type MouseEvent } from 'react'import { flushSync } from 'react-dom'import { AuthLayout, PasswordInput, checkEmail, messages, useForm, type AuthLink } from '@rchursin/product/forms'import { Button, Field, TextInput, useToast } from '@rchursin/product/ui'
const link = 'lux-link lux-link-under rounded-sm font-medium text-fg focus-ring'
/** Sample footer links: '#' addresses, so nothing here points at pages this site doesn't have */const demoLinks: AuthLink[] = [ { label: 'Terms', href: '#terms' }, { label: 'Privacy', href: '#privacy' },]
/** * A whole sign-in page: AuthLayout is the <main>, the page has one <h1>, and the form checks * itself on submit. The brand panel joins on the left once the page is 48rem wide. */export default function AuthLayoutPage() { const toast = useToast() const [status, setStatus] = useState('')“Continue with GitHub” and “Continue with Google” at equal weight, with a waiting state while the provider answers.
import { useState } from 'react'import { SocialButtons, type Provider } from '@rchursin/product/forms'
const names: Record<Provider, string> = { github: 'GitHub', google: 'Google' }
/** Press one: it waits for the provider with a spinner (still focusable) while the other steps back. */export default function SocialButtonsStack() { const [pending, setPending] = useState<Provider | null>(null) const [result, setResult] = useState('')
function select(p: Provider) { setPending(p) setResult('') // In the app: redirect to the provider, or call api.signInWithProvider(p) window.setTimeout(() => { setPending(null) setResult(`Sample: ${names[p]} would open its sign-in page here.`) }, 1400) }
return ( <div className="flex w-full max-w-xs flex-col gap-3"> <SocialButtons onSelect={select} pending={pending} /> <p role="status" className="min-h-5 text-center text-xs text-pretty text-fg-3"> {pending ? `Contacting ${names[pending]}…` : result} </p> </div> )}An advisory meter that guesses like an attacker, plus the blocklist check. Four grey segments and a word; it never blocks.
import { useState } from 'react'import { PasswordInput, PasswordStrength } from '@rchursin/product/forms'import { Field } from '@rchursin/product/ui'
/** The person's own name and email, so the meter can spot them in the password */const account = { name: 'Alex Rivera', email: 'alex@example.com' }
/** * The meter sits under the field and is part of its description, so screen readers hear it * with the field, not after every key. It advises; only the length and the blocklist can block. */export default function PasswordStrengthNewPassword() { const [password, setPassword] = useState('') return ( <div className="flex w-full max-w-sm flex-col gap-4"> <p className="text-xs text-fg-3"> Signing up as {account.name}, {account.email} <span className="font-mono text-[10px] tracking-[0.12em] uppercase">(sample)</span> </p> <Field label="New password" hint="At least 15 characters."> {(p) => ( <> <PasswordInput {...p} size="lg" autoComplete="new-password" describedBy={`${p.id}-strength`} value={password} onChange={(e) => setPassword(e.target.value)} /> <PasswordStrength id={`${p.id}-strength`} password={password} inputs={[account.name, account.email]} /> </> )} </Field> </div> )}The in-app settings form: profile with a picture and time zone, a password change, passkeys and sessions, with an error summary on save.
import { AccountSettings, DEMO_ACCOUNT } from '@rchursin/product/forms'
/** * The whole settings form inside the app window. It brings its own sample data and saves it to * this browser; “Reset sample data” in its toolbar brings the sample back. */export default function AccountSettingsApp() { return ( <div className="flex flex-col gap-3"> <p className="self-start rounded-xl px-3.5 py-2 text-xs text-fg-2 outline-1 -outline-offset-1 outline-fg/30 outline-dashed"> <span className="mr-2 font-mono text-[10px] font-medium tracking-[0.14em] text-fg-3 uppercase">Demo</span> The sample’s current password is <code className="font-mono text-[11px] whitespace-nowrap text-fg">{DEMO_ACCOUNT.password}</code> </p> <AccountSettings /> </div> )}useForm for values, errors and validation timing, plus the checks and GOV.UK-style messages every form in the kit shares.
import { useRef, useState, type FormEvent } from 'react'import { flushSync } from 'react-dom'import { checkEmail, checkName, useForm } from '@rchursin/product/forms'import { Button, Field, Select, TextInput } from '@rchursin/product/ui'
const empty = { name: '', email: '', role: 'Member' }const roles = ['Member', 'Admin', 'Viewer']
/** * useForm with the shared checks. Everything is checked on submit and focus goes to the first * field to fix; a field with an error re-checks as you type; the email's format is also checked * when you leave it, but only after a change. */export default function FormToolkitUseForm() { const form = useForm(empty, { name: (v) => checkName(v.name), email: (v) => checkEmail(v.email) }, { onBlur: ['email'] }) const refs = { name: useRef<HTMLInputElement>(null), email: useRef<HTMLInputElement>(null) } const [sent, setSent] = useState('')
function submit(e: FormEvent) { e.preventDefault()Checks like GOV.UK, keeps every word through a failed send, and sends through your function, Formspree, Web3Forms or no backend at all.
import { ContactForm, type ContactTransport } from '@rchursin/product/contact'
/** * A pretend backend: it waits a moment, then says the message was sent. Nothing leaves the page. * On a real site, use createContactTransport (see Usage). */const demoTransport: ContactTransport = { canSend: true, send: () => new Promise((resolve) => window.setTimeout(() => resolve({ ok: true }), 900)),}
export default function ContactFormDemo() { return ( <div className="w-full max-w-lg"> <ContactForm transport={demoTransport} email="hello@example.com" replyWindow="within 2 working days" bookingUrl="https://example.com/book" headingLevel={3} note="Demo: messages here go nowhere. On a real site, say what happens to them." /> </div> )}quiet-graphite-lampCode 424242Welcome back. Enter your email, or use a passkey.
Welcome back. Use the email you signed up with.
Signing up as Alex Rivera, alex@example.com (sample)
At least 15 characters.
Password strength: Not set
Use 15 or more characters. A few unrelated words work well.