Contact Section
A contact block whose subject is the submit state machine — sending, confirmed, or failed with every character preserved — beside contact rows that derive their own mailto/tel/map links.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/contact-section.jsonPrompt
Build a React + TypeScript + Tailwind "ContactSection" block (zod, lucide-react).
Contract
- export const contactFormSchema = z.object({ name, email, subject, message })
with .trim() on the string fields so parsed output IS the payload: name 1–80,
email trimmed then piped into z.email() (the native rule accepts "a@b", which
mail providers bounce), subject non-empty, message 10–2000 after trimming.
export type ContactFormValues = z.infer<typeof contactFormSchema>.
- Props: onSubmit: (values: ContactFormValues) => Promise<void> (required — the
block never fetches anything itself), heading, description, channels, subjects,
defaultValues, map (ReactNode slot), footnote, messages, className, plus the
rest of the native section props spread onto the root. forwardRef to the
<section>; data-status carries the current state for host styling.
- ContactChannel = { type: "email" | "phone" | "address" | "hours"; label;
value; href?; note? }. `value` may contain "\n" for multi-line addresses and
opening hours.
- `messages` is a Partial of one exported CONTACT_MESSAGES object holding every
built-in string — labels, placeholders, button labels, the network-failure
sentence, the confirmation copy. The FIELD validation messages deliberately
live in the schema instead, so a server importing it reuses the same wording.
Behavior
- The state machine is the product: idle → submitting → success | error.
* idle: a real <form onSubmit> (never a button with onClick — that is the only
way Enter inside a field submits) with noValidate, so the browser's own
bubbles never race zod for the same field.
* submit: zod safeParse; on failure map issues to per-field messages, keeping
the FIRST issue per field, then move focus to the first invalid control in
tab order. Field messages are plain <p id> wired through aria-describedby +
aria-invalid — not four simultaneous role="alert" nodes, which would talk
over the focus move that just delivered the same text.
* submitting: a ref flipped synchronously at the top of the handler is the
real duplicate guard (five presses in one tick would all read the same stale
state). Inputs take the native disabled attribute; the submit button takes
aria-disabled + aria-busy and NEVER the native attribute, because the browser
blurs a control the moment it is disabled. A layout effect notices focus has
fallen to <body> and hands it to the submit button.
* error: the rejection becomes a role="alert" banner (an Error's message shown
verbatim so a server can explain itself; a bare string is used as-is and
anything else falls back to the built-in sentence), the button relabels to
"Try again", and every field keeps its value. Editing clears that field's
error but leaves the banner — the reason the send failed is still true while
it is being fixed. A retry that fails VALIDATION does clear it and returns to
idle: that press never reached the server, so "the relay is down" beside
"that isn't an email address" would blame the wrong thing.
* success: the form is replaced by a confirmation panel that repeats the
address and the chosen topic, is focused (tabIndex={-1} + aria-labelledby +
aria-describedby), and offers "send another message" — which clears topic and
message but keeps name and email, because the second message is from the same
person. Focus is only taken if it is still inside the block.
- One sr-only role="status" node is mounted for the life of the block and carries
the pending sentence; a live region inserted together with its text announces
unreliably.
- The message counter is derived from the trimmed length and there is no native
maxLength: an over-long paste must be reported, not silently truncated.
- Contact rows derive their own links from their own values — mailto: from the
email, tel: from the phone with everything but + and digits stripped. An
explicit href wins (an address needs a real map URL). Empty and "#" are
ignored: a row with nowhere to go renders as plain text, never a dead link.
http(s) links get target=_blank + rel=noreferrer.
Rendering & styling
- Semantic tokens only: bg-card form panel, bg-background controls with
border-input, text-muted-foreground for labels and notes, destructive for
errors and the over-limit counter, primary for the submit button and the
confirmation badge. No hardcoded colors or radii.
- Container queries, not viewport breakpoints: the root is @container/block and
splits into details + form at @[46rem]; the form card is its own
@container/form and pairs name+email at @[30rem]. The block is then correct
inside a sidebar, a modal or a full-width page without being told which.
- Every control has a real <label htmlFor>; min-w-0 + break-words on the channel
column so a long address cannot push the layout sideways.
- The topic dropdown is a native <select> styled with appearance-none and an
overlaid chevron, with explicit token colors on the <option> rows — browsers
that paint the popup from the control's colors would otherwise draw near-white
text on a white listbox in dark mode.
- Respect prefers-reduced-motion: motion-reduce:transition-none on the colour
transitions, motion-reduce:animate-none on the pending spinner. The spinner is
decoration; the label text ("Sending…") is what actually reports the state.
Customization levers
- Column split: swap the @[46rem] grid template (4fr/5fr) for 1fr/1fr, or drop
the details column entirely with channels={[]} and let the form span the block.
- Channels: any length and any mix of the four types; add a type by extending the
icon map and the href derivation together, so a new row cannot get an icon
without a link rule.
- Field set: message is the only textarea; adding "company" or "budget" means one
more schema key, one more Field, and one more entry in the tab-order array that
drives first-invalid focus.
- Validation strictness: the schema is the single place to change limits — raise
CONTACT_MESSAGE_MAX, require a company domain with .refine, or make the topic
a z.enum of your own option values.
- Copy and locale: pass `messages` for any subset of the built-in strings.
- Reset policy: "send another message" currently keeps name and email; clear them
in the same updater for a fully blank second form.Concepts
- Submit state machine —
idle → submitting → success | erroris the whole product; the layout is what is left over once the four states are honest. A contact form that only draws the idle state is a screenshot. - Synchronous duplicate guard — the "already sending" flag is a ref flipped at the top of the handler, not state: several presses inside one tick would all read the same stale render value, and the endpoint would get several copies of the message.
- Preservation on failure — a rejection changes the status and mounts a banner and nothing else. Clearing the fields on error is how a page turns one flaky request into a visitor who leaves.
- Focus as the error channel — validation moves focus to the first offending control, whose
aria-describedbyalready points at its message, so a screen reader reads the problem where the fix happens instead of firing four alerts at once. aria-disabledfor the busy button — the nativedisabledattribute makes the browser blur the element instantly, so the keyboard user who pressed Enter would lose the control they are waiting on; the ref guard is what actually blocks the second request.- Derived channel links —
mailto:andtel:are computed from the value printed beside them, so the link cannot drift from the text; a row with no derivable and no explicit destination renders as text rather than a#that goes nowhere.
Team Grid
A four-state team section — equal-height member cards with initials-fallback avatars, optional bios and real social links, groupable by department.
Blog Card Grid
A four-state article grid — one stretched link per card with the category and author still independently clickable, a featured post spanning two columns, and deliberate, configurable line clamping so mismatched title lengths still line up.