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/ui/badge";
import { Button } from "@/registry/hirael/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/registry/hirael/ui/card";
import { Checkbox } from "@/registry/hirael/ui/checkbox";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/registry/hirael/ui/field";
import { Input } from "@/registry/hirael/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/registry/hirael/ui/select";
import { Separator } from "@/registry/hirael/ui/separator";
import { Textarea } from "@/registry/hirael/ui/textarea";
const MESSAGE_MAX = 1000;
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
type 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;
type 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: "#",
},
];
function 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;
}
export default function 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"
>
{messageRemaining} / {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">
I've read the{" "}
<a
href="#"
className="underline underline-offset-4 hover:text-primary"
>
privacy notice
</a>
.
</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>
);
}
Dependencies
shadcn registry
badgebuttoncardcheckboxfieldinputselectseparatortextarea
npm
lucide-react