Blocks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ChevronDown,
  CircleAlert,
  CircleCheck,
  Clock,
  LoaderCircle,
  Mail,
  MapPin,
  Phone,
} from "lucide-react"
import { z } from "zod"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/contact-section.json

Prompt

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 machineidle → submitting → success | error is 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-describedby already points at its message, so a screen reader reads the problem where the fix happens instead of firing four alerts at once.
  • aria-disabled for the busy button — the native disabled attribute 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 linksmailto: and tel: 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.

On This Page