Contact 1
Production contact form with controlled state, inline validation, character counter, topic select, consent checkbox and a pending/sent state machine. Channel list and remote-location card alongside.
Preview
Installation
npx shadcn@latest add https://hirael.com/r/contact-01.jsonCode
components/blocks/contact-01.tsx
'use client';
import * as React from 'react';
import { ArrowRight, CheckCircle2, Loader2, Mail, MapPin, MessageCircle } from 'lucide-react';
import { Badge } from '@/registry/hirael/bases/radix/ui/badge';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/registry/hirael/bases/radix/ui/card';
import { Checkbox } from '@/registry/hirael/bases/radix/ui/checkbox';
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '@/registry/hirael/bases/radix/ui/field';
import { Input } from '@/registry/hirael/bases/radix/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/registry/hirael/bases/radix/ui/select';
import { Separator } from '@/registry/hirael/bases/radix/ui/separator';
import { Textarea } from '@/registry/hirael/bases/radix/ui/textarea';
const MESSAGE_MAX = 1000;
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
interface FormState {
name: string;
email: string;
company: string;
topic: string;
message: string;
consent: boolean;
}
type FormErrors = Partial<Record<keyof FormState, string>>;
const INITIAL: FormState = {
name: '',
email: '',
company: '',
topic: 'general',
message: '',
consent: false,
};
const TOPICS = [
{ value: 'general', label: 'General question' },
{ value: 'sales', label: 'Sales / pricing' },
{ value: 'partnerships', label: 'Partnership' },
{ value: 'support', label: 'Technical support' },
] as const;
interface Channel {
icon: React.ElementType;
label: string;
value: string;
href: string;
}
const CHANNELS: readonly Channel[] = [
{
icon: Mail,
label: 'Email',
value: 'hello@hirael.com',
href: 'mailto:hello@hirael.com',
},
{
icon: MessageCircle,
label: 'Discord',
value: 'hirael · #help',
href: '#',
},
];
const validate = (state: FormState): FormErrors => {
const errors: FormErrors = {};
if (!state.name.trim()) errors.name = 'Tell us who you are.';
if (!state.email.trim()) errors.email = 'We need a way to reply.';
else if (!EMAIL_PATTERN.test(state.email)) errors.email = "That doesn't look like a valid email.";
if (state.message.trim().length < 20) errors.message = 'A little more detail helps us route your note.';
if (state.message.length > MESSAGE_MAX) errors.message = `Keep it under ${MESSAGE_MAX} characters.`;
if (!state.consent) errors.consent = 'Please accept the privacy notice.';
return errors;
};
const Contact01 = () => {
const [state, setState] = React.useState<FormState>(INITIAL);
const [errors, setErrors] = React.useState<FormErrors>({});
const [touched, setTouched] = React.useState(false);
const [status, setStatus] = React.useState<'idle' | 'sending' | 'sent'>('idle');
const set = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setState((s) => ({ ...s, [key]: value }));
if (touched) {
setErrors(validate({ ...state, [key]: value }));
}
};
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setTouched(true);
const next = validate(state);
setErrors(next);
if (Object.keys(next).length > 0) return;
setStatus('sending');
await new Promise((r) => setTimeout(r, 900));
setStatus('sent');
};
const reset = () => {
setState(INITIAL);
setErrors({});
setTouched(false);
setStatus('idle');
};
const messageRemaining = MESSAGE_MAX - state.message.length;
return (
<section className="bg-background py-20 sm:py-28">
<div className="container w-full">
<div className="flex max-w-2xl flex-col gap-5">
<Badge variant="outline" className="w-fit">
<span className="size-1 rounded-full bg-foreground" />
Talk to us
</Badge>
<h2 className="font-serif text-4xl font-medium leading-[1.04] tracking-tight sm:text-5xl">
Have a question we haven't answered yet?
</h2>
<p className="text-base text-muted-foreground sm:text-lg">
Drop a note and we'll route it to the right person. Engineering questions, sales, partnerships: all the
same form.
</p>
</div>
<div className="mt-14 grid grid-cols-1 gap-10 lg:grid-cols-12 lg:gap-16">
<Card className="lg:col-span-7">
<CardHeader>
<CardTitle>Send a message</CardTitle>
<CardDescription>We reply within one business day, usually faster.</CardDescription>
</CardHeader>
<CardContent>
{status === 'sent' ? (
<div
role="status"
aria-live="polite"
className="flex flex-col items-start gap-4 rounded-md border border-dashed border-border bg-card/40 p-6"
>
<span className="inline-flex size-9 items-center justify-center rounded-full bg-success/10 text-success">
<CheckCircle2 className="size-5" />
</span>
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold tracking-[-0.01em]">Message sent.</h3>
<p className="text-sm text-muted-foreground">
Thanks{state.name ? `, ${state.name.split(' ')[0]}` : ''}. We'll be in touch at{' '}
<span className="font-mono text-foreground">{state.email}</span>.
</p>
</div>
<Button variant="outline" size="sm" onClick={reset}>
Send another
</Button>
</div>
) : (
<form noValidate onSubmit={onSubmit} className="contents">
<FieldGroup>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<Field data-invalid={Boolean(errors.name) || undefined}>
<FieldLabel htmlFor="contact-name">Name</FieldLabel>
<Input
id="contact-name"
name="name"
autoComplete="name"
placeholder="Ada Lovelace"
value={state.name}
onChange={(e) => set('name', e.target.value)}
aria-invalid={Boolean(errors.name) || undefined}
aria-describedby={errors.name ? 'contact-name-error' : undefined}
/>
<FieldError id="contact-name-error">{errors.name}</FieldError>
</Field>
<Field data-invalid={Boolean(errors.email) || undefined}>
<FieldLabel htmlFor="contact-email">Work email</FieldLabel>
<Input
id="contact-email"
name="email"
type="email"
autoComplete="email"
placeholder="ada@studio.io"
value={state.email}
onChange={(e) => set('email', e.target.value)}
aria-invalid={Boolean(errors.email) || undefined}
aria-describedby={errors.email ? 'contact-email-error' : undefined}
/>
<FieldError id="contact-email-error">{errors.email}</FieldError>
</Field>
</div>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="contact-company">
Company{' '}
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
optional
</span>
</FieldLabel>
<Input
id="contact-company"
name="company"
autoComplete="organization"
placeholder="Plinth Labs"
value={state.company}
onChange={(e) => set('company', e.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="contact-topic">Topic</FieldLabel>
<Select value={state.topic} onValueChange={(v) => set('topic', v)}>
<SelectTrigger id="contact-topic" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TOPICS.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<Field data-invalid={Boolean(errors.message) || undefined}>
<FieldLabel htmlFor="contact-message">Message</FieldLabel>
<Textarea
id="contact-message"
name="message"
rows={5}
maxLength={MESSAGE_MAX + 50}
placeholder="Tell us what you’re building, and what’s in your way…"
value={state.message}
onChange={(e) => set('message', e.target.value)}
aria-invalid={Boolean(errors.message) || undefined}
aria-describedby="contact-message-help contact-message-error"
/>
<div className="flex items-center justify-between">
<FieldDescription id="contact-message-help">
Plain text. Code snippets welcome.
</FieldDescription>
<span
className={`font-mono text-[10px] tabular-nums ${
messageRemaining < 0 ? 'text-destructive' : 'text-muted-foreground'
}`}
aria-live="polite"
>
{state.message.length} / {MESSAGE_MAX}
</span>
</div>
<FieldError id="contact-message-error">{errors.message}</FieldError>
</Field>
<Field orientation="horizontal" data-invalid={Boolean(errors.consent) || undefined}>
<Checkbox
id="contact-consent"
checked={state.consent}
onCheckedChange={(v) => set('consent', v === true)}
aria-describedby={errors.consent ? 'contact-consent-error' : undefined}
/>
<div className="flex flex-1 flex-col gap-1">
<FieldLabel htmlFor="contact-consent">
<span>
I've read the{' '}
<a href="#" className="underline underline-offset-4 hover:text-primary">
privacy notice
</a>
.
</span>
</FieldLabel>
<FieldError id="contact-consent-error">{errors.consent}</FieldError>
</div>
</Field>
<Separator />
<div className="flex flex-col-reverse items-stretch justify-between gap-3 sm:flex-row sm:items-center">
<p className="font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
Routed to the right team automatically.
</p>
<Button type="submit" size="lg" disabled={status === 'sending'} className="group sm:w-fit">
{status === 'sending' ? (
<>
<Loader2 className="size-4 animate-spin" />
Sending…
</>
) : (
<>
Send message
<ArrowRight className="size-4 transition-transform duration-150 ease-out group-hover:translate-x-0.5 rtl:rotate-180 rtl:group-hover:-translate-x-0.5" />
</>
)}
</Button>
</div>
</FieldGroup>
</form>
)}
</CardContent>
</Card>
<aside className="flex flex-col gap-6 lg:col-span-5">
<Card>
<CardHeader>
<CardTitle className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
other ways to reach us
</CardTitle>
</CardHeader>
<CardContent className="px-0">
<ul className="flex flex-col">
{CHANNELS.map((c, i) => {
const Icon = c.icon;
return (
<li key={c.label}>
<a
href={c.href}
className={`group flex items-center justify-between gap-4 px-6 py-3.5 transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:bg-accent/60 ${i < CHANNELS.length - 1 ? 'border-b border-border' : ''}`}
>
<span className="flex items-center gap-3">
<span className="inline-flex size-8 items-center justify-center rounded-sm border border-border bg-background text-foreground">
<Icon className="size-3.5" />
</span>
<span className="flex flex-col">
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
{c.label}
</span>
<span className="text-sm font-medium text-foreground">{c.value}</span>
</span>
</span>
<ArrowRight className="size-4 text-muted-foreground opacity-0 transition-all duration-150 ease-out group-hover:translate-x-0.5 group-hover:opacity-100 rtl:rotate-180 rtl:group-hover:-translate-x-0.5" />
</a>
</li>
);
})}
</ul>
</CardContent>
</Card>
<Card className="border-dashed bg-card/30">
<CardContent>
<div className="flex items-start gap-3">
<span className="inline-flex size-8 items-center justify-center rounded-sm border border-border bg-background text-foreground">
<MapPin className="size-3.5" />
</span>
<div className="flex flex-col gap-1">
<p className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
Remote, mostly
</p>
<p className="text-sm text-foreground">Distributed across UTC-5 → UTC+3.</p>
<p className="text-sm text-muted-foreground">Office hours 09:00–17:00 local time.</p>
</div>
</div>
</CardContent>
</Card>
</aside>
</div>
</div>
</section>
);
};
export default Contact01;
Dependencies
shadcn registry
badgebuttoncardcheckboxfieldinputselectseparatortextarea
npm
lucide-react