Skabelon · React · HTML
Contact Form
En kontaktformular i to fliser med emnevalg, tegntæller, valgfrit samtykke og en infoflise med svartiden.
Forhåndsvisning
React
Installér skabelonen med shadcn. Den lægges i dit projekt som almindelige komponenter, som du selv retter i.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/contact-form.jsonnpx shadcn@latest add @manniche/contact-formcontact-form.tsx
// Contact form: a two-tile split. The form tile holds name, email, topic (Pills), message with a mono character
// count, and an optional consent checkbox; the info tile holds email, phone, address and the response time.
// Signature: the count turns destructive, with the words "12 over", only when over the limit.
// Screen readers: real labels, aria-invalid + aria-describedby on errors, focus moves to the first invalid field,
// and sending, sent and error states are announced through a live region. The form never sends anything itself:
// onSubmit returns a promise the section awaits. On a narrow tile the topic Pills become a native select.
// Reduced motion: the press feedback on the send button is dropped; nothing else moves.
import { useEffect, useId, useRef, useState, type FormEvent, type HTMLAttributes, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { Pills, type PillOption } from '@/registry/manniche/chart-kit/chart-kit'
export type ContactFormValues = { name: string; email: string; topic: string; message: string; consent: boolean }
export type ContactInfo = {
email?: string
phone?: string
address?: ReactNode
/** "We reply within two working days." */
responseTime?: ReactNode
}
export type ContactFormProps = Omit<HTMLAttributes<HTMLElement>, 'onSubmit' | 'title'> & {
/** The section heading (h2). */
heading: ReactNode
/** One line under the heading. */
intro?: ReactNode
/** Topics shown as Pills. */
topics: PillOption[]
/** Message limit in characters. Default 800. */
maxLength?: number
/** Consent line (may hold a privacy link). The checkbox only appears when this is passed. */
consent?: ReactNode
/** Content of the info tile. */
info: ContactInfo
/** Called with the values; the section waits for it. Reject to show the error state. */
onSubmit?: (values: ContactFormValues) => Promise<void> | void
labels?: {
name?: string; email?: string; topic?: string; message?: string; send?: string; sending?: string
sentTitle?: string; sentText?: string; another?: string; errorText?: string; retry?: string
nameError?: string; emailError?: string; messageError?: string; consentError?: string
over?: (n: number) => string; infoTitle?: string; emailLabel?: string; phoneLabel?: string; addressLabel?: string
}
}
type Status = 'idle' | 'sending' | 'sent' | 'error'
type Errors = Partial<Record<'name' | 'email' | 'message' | 'consent', string>>
const TILE = 'rounded-[26px] bg-card text-card-foreground shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent),0_1px_2px_rgba(0,0,0,0.03)]'
const FIELD =
'mt-1.5 block min-h-11 w-full rounded-[14px] border border-border bg-background px-3.5 text-base outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring aria-[invalid=true]:border-destructive'
export function ContactForm({ heading, intro, topics, maxLength = 800, consent, info, onSubmit, labels = {}, className, ...rest }: ContactFormProps) {
const L = {
name: 'Name', email: 'Email', topic: 'Topic', message: 'Message', send: 'Send message', sending: 'Sending',
sentTitle: 'Message sent', sentText: 'Thank you. We will be in touch.', another: 'Write another',
errorText: 'The message did not go through. Your text is still here, so you can try again.', retry: 'Try again',
nameError: 'Enter your name.', emailError: 'Enter an email address like name@example.com.',
messageError: 'Write a message, or shorten it to the limit.', consentError: 'Tick the box to agree before sending.',
over: (n: number) => `${n} over`, infoTitle: 'Other ways to reach us', emailLabel: 'Email', phoneLabel: 'Phone', addressLabel: 'Address',
...labels,
}
const uid = useId()
const [values, setValues] = useState<ContactFormValues>({ name: '', email: '', topic: topics[0]?.id ?? '', message: '', consent: false })
const [errors, setErrors] = useState<Errors>({})
const [status, setStatus] = useState<Status>('idle')
const formRef = useRef<HTMLFormElement>(null)
const sentRef = useRef<HTMLHeadingElement>(null)
const reset = useRef(false)
// The form disappears when sent, so focus moves to the confirmation instead of being lost; after "Write another"
// it moves back to the first field.
useEffect(() => {
if (status === 'sent') sentRef.current?.focus()
if (status === 'idle' && reset.current) {
reset.current = false
formRef.current?.querySelector<HTMLElement>('[name="name"]')?.focus()
}
}, [status])
const validate = (v: ContactFormValues): Errors => {
const e: Errors = {}
if (!v.name.trim()) e.name = L.nameError
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.email.trim())) e.email = L.emailError
if (!v.message.trim() || v.message.length > maxLength) e.message = L.messageError
if (consent && !v.consent) e.consent = L.consentError
return e
}
const set = <K extends keyof ContactFormValues>(k: K, val: ContactFormValues[K]) => setValues((v) => ({ ...v, [k]: val }))
const blur = (k: keyof Errors) => setErrors((e) => ({ ...e, [k]: validate(values)[k] }))
const submit = async (ev: FormEvent) => {
ev.preventDefault()
if (status === 'sending') return
const e = validate(values)
setErrors(e)
const first = (['name', 'email', 'message', 'consent'] as const).find((k) => e[k])
if (first) {
formRef.current?.querySelector<HTMLElement>(`[name="${first}"]`)?.focus()
return
}
setStatus('sending')
try {
await onSubmit?.(values)
setStatus('sent')
} catch {
setStatus('error')
}
}
const left = maxLength - values.message.length
const over = left < 0
const err = (k: keyof Errors) => (errors[k] ? `${uid}-${k}-err` : undefined)
const errMsg = (k: keyof Errors) =>
errors[k] ? (
<p id={`${uid}-${k}-err`} className="mt-1.5 text-sm text-destructive">
{errors[k]}
</p>
) : null
return (
<section aria-labelledby={`${uid}-h`} className={cn('@container mx-auto w-full max-w-6xl px-4 py-12 sm:px-6', className)} {...rest}>
<header className="max-w-2xl">
<h2 id={`${uid}-h`} className="font-[family-name:var(--font-display,inherit)] text-3xl font-semibold tracking-tight text-balance @min-[40rem]:text-4xl">
{heading}
</h2>
{intro && <p className="mt-3 text-muted-foreground">{intro}</p>}
</header>
<div className="mt-8 grid gap-3 @min-[52rem]:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
<div className={cn(TILE, '@container/form p-6')}>
<div role="status" aria-live="polite" className="sr-only">
{status === 'sending' ? L.sending : status === 'sent' ? L.sentTitle : status === 'error' ? L.errorText : ''}
</div>
{status === 'sent' ? (
<div>
<h3 ref={sentRef} tabIndex={-1} className="text-xl font-semibold outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring">{L.sentTitle}</h3>
<p className="mt-2 text-muted-foreground">{L.sentText}</p>
<p className="mt-4 font-mono text-xs tabular-nums text-muted-foreground">{values.email}</p>
<button
type="button"
onClick={() => {
setValues({ name: '', email: '', topic: topics[0]?.id ?? '', message: '', consent: false })
reset.current = true
setStatus('idle')
}}
className="mt-6 inline-flex min-h-11 items-center rounded-full bg-muted px-4 text-sm font-medium outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring"
>
{L.another}
</button>
</div>
) : (
<form ref={formRef} onSubmit={submit} noValidate className="grid gap-5">
<div>
<label htmlFor={`${uid}-name`} className="text-sm font-medium">{L.name}</label>
<input id={`${uid}-name`} name="name" type="text" autoComplete="name" value={values.name}
onChange={(e) => set('name', e.target.value)} onBlur={() => blur('name')}
aria-invalid={!!errors.name} aria-describedby={err('name')} className={FIELD} />
{errMsg('name')}
</div>
<div>
<label htmlFor={`${uid}-email`} className="text-sm font-medium">{L.email}</label>
<input id={`${uid}-email`} name="email" type="email" autoComplete="email" inputMode="email" value={values.email}
onChange={(e) => set('email', e.target.value)} onBlur={() => blur('email')}
aria-invalid={!!errors.email} aria-describedby={err('email')} className={FIELD} />
{errMsg('email')}
</div>
{topics.length > 1 && (
<div>
<p id={`${uid}-topic`} className="mb-1.5 text-sm font-medium">{L.topic}</p>
{/* Pills cannot wrap, so a narrow tile gets the same choice as a native select. */}
<div className="hidden @min-[26rem]/form:block">
<Pills label={L.topic} options={topics} value={values.topic} onChange={(t) => set('topic', t)} />
</div>
<select name="topic" aria-labelledby={`${uid}-topic`} value={values.topic} onChange={(e) => set('topic', e.target.value)}
className={cn(FIELD, 'mt-0 @min-[26rem]/form:hidden')}>
{topics.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
</div>
)}
<div>
<label htmlFor={`${uid}-message`} className="text-sm font-medium">{L.message}</label>
<textarea id={`${uid}-message`} name="message" rows={6} value={values.message}
onChange={(e) => set('message', e.target.value)} onBlur={() => blur('message')}
aria-invalid={!!errors.message} aria-describedby={[err('message'), `${uid}-count`].filter(Boolean).join(' ')}
className={cn(FIELD, 'min-h-32 resize-y py-2.5')} />
<p id={`${uid}-count`} className={cn('mt-1.5 text-right font-mono text-xs tabular-nums', over ? 'text-destructive' : 'text-muted-foreground')}>
{over ? L.over(-left) : `${values.message.length} / ${maxLength}`}
</p>
{errMsg('message')}
</div>
{consent && (
<div>
<label className="flex min-h-11 items-start gap-3 text-sm">
<input name="consent" type="checkbox" checked={values.consent}
onChange={(e) => set('consent', e.target.checked)} onBlur={() => blur('consent')}
aria-invalid={!!errors.consent} aria-describedby={err('consent')}
className="mt-0.5 size-5 shrink-0 accent-[var(--primary)] outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring" />
<span>{consent}</span>
</label>
{errMsg('consent')}
</div>
)}
{status === 'error' && <p className="text-sm text-destructive">{L.errorText}</p>}
{/* aria-disabled only: a disabled button would drop keyboard focus to the body while sending. submit() ignores repeats. */}
<button type="submit" aria-disabled={status === 'sending'}
className="inline-flex min-h-11 w-fit items-center justify-center rounded-full bg-primary px-6 text-sm font-medium text-primary-foreground outline-offset-2 transition-[opacity,transform] duration-200 ease-out-quint focus-visible:outline-2 focus-visible:outline-ring active:scale-[0.98] aria-disabled:opacity-60 motion-reduce:transition-none motion-reduce:active:scale-100">
{status === 'sending' ? L.sending : status === 'error' ? L.retry : L.send}
</button>
</form>
)}
</div>
<aside aria-labelledby={`${uid}-info`} className={cn(TILE, 'h-fit p-6')}>
<h3 id={`${uid}-info`} className="text-lg font-semibold">{L.infoTitle}</h3>
<dl className="mt-4 grid gap-4 text-sm">
{info.email && (
<div><dt className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground">{L.emailLabel}</dt>
<dd className="break-words"><a className="inline-flex min-h-11 items-center underline underline-offset-4 outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring" href={`mailto:${info.email}`}>{info.email}</a></dd></div>
)}
{info.phone && (
<div><dt className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground">{L.phoneLabel}</dt>
<dd className="tabular-nums"><a className="inline-flex min-h-11 items-center underline underline-offset-4 outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring" href={`tel:${info.phone.replace(/[^\d+]/g, '')}`}>{info.phone}</a></dd></div>
)}
{info.address && (
<div><dt className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground">{L.addressLabel}</dt>
<dd className="mt-1">{info.address}</dd></div>
)}
</dl>
{info.responseTime && <p className="mt-6 border-t border-border pt-4 font-mono text-xs text-muted-foreground">{info.responseTime}</p>}
</aside>
</div>
</section>
)
}contact-form-demo.tsx
import { ContactForm } from '@/components/contact-form'
export default function ContactFormDemo() {
return (
<ContactForm
heading="Tell us what you are building"
intro="A few lines are enough. A person at Halden Studio reads every message."
topics={[
{ id: 'project', label: 'New project' },
{ id: 'support', label: 'Support' },
{ id: 'other', label: 'Other' },
]}
maxLength={400}
consent={<>I agree that Halden Studio may store this message to answer it. See the <a className="underline underline-offset-4" href="https://example.com/privacy">privacy policy</a>.</>}
info={{
email: 'hello@example.com',
phone: '+49 89 0000 0000',
address: <>Example Street 1<br />80331 Munich</>,
responseTime: 'We reply within two working days.',
}}
onSubmit={() => new Promise<void>((r) => setTimeout(r, 900))}
/>
)
}HTML
Hele siden som én fil med al CSS indbygget og intet JavaScript. Knapper, faner og filtre virker med HTML og CSS alene.
Farven i din egen app
Har du allerede shadcn sat op, giver disse variabler samme farve. Lys i :root, mørk i .dark.
:root {
--primary: oklch(0.52 0.2 262);
--primary-foreground: oklch(0.99 0 0);
--accent: oklch(0.95 0.034 262);
--accent-foreground: oklch(0.36 0.140 262);
--ring: oklch(0.52 0.2 262);
}
.dark {
--primary: oklch(0.74 0.150 262);
--primary-foreground: oklch(0.18 0.024 262);
--accent: oklch(0.29 0.050 262);
--accent-foreground: oklch(0.9 0.050 262);
--ring: oklch(0.74 0.150 262);
}Installér
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/contact-form.json