Skip to content
AstroCraft Docs
On this theme

Contact Form

Read this first. Develi ships the contact form’s markup, validation and layout — but not its submission. The <form> element has no action, no method and no submit handler. Pressing “Send message” validates the fields and then does nothing useful.

This is a consequence of the theme being fully static with no adapter: there is no server route to post to and no mail provider assumed. Wiring a destination is a required launch step, not an optional enhancement. Options are at the bottom of this chapter.

Everything else about the form is finished and worth keeping. The validation, the field types, the accessibility and the consent handling are all done properly, and they are the parts most people get wrong.

What ships

/contact/ is three sections: ContactHero, ContactForm and SupportOptions.

ContactForm is a two-column panel — copy and a client quote on the left, the enquiry form on the right — laid out as a ratio rather than fixed widths so the proportion holds at any container width:

<div class="grid gap-12 lg:grid-cols-[minmax(0,1fr)_1.35fr] lg:items-start lg:gap-16">

It breaks at lg rather than md for a measured reason: the form’s own two-up name/company row needs about 440px to stay comfortable, and at md’s 736px the pair would leave the copy column narrower than the quote card it holds.

The panel itself uses the shared .recessed-panel class rather than the ui/card primitive — that primitive’s job is a card surface on a page ground, and this is the exact inverse, a deep surface on a bg-card band.

The fields

Field Type Required Autocomplete
Full name text yes name
Company text no organization
Email email yes email
Phone tel no tel
Message textarea yes
Consent checkbox yes

Every field has a real <label for>, every one carries an autocomplete token, and the phone field has a hint wired through aria-describedby.

Validation is the platform’s, not the theme’s

The form has required on four controls and type="email" on one, and that is the whole validation layer. The browser checks required fields and email format, with real error messages in the reader’s own language, for zero bytes of JavaScript.

Two field-type decisions are worth copying:

type="tel", never type="number". A phone number is a string of digits with separators and a leading +, not a quantity. number would strip the +, offer a spinner, and reject half the world’s formats.

No pattern on either the phone or the email. A well-meant phone regex rejects valid international numbers, and a well-meant email regex rejects valid addresses. type="email" gets both the format check and the right mobile keyboard; that is enough.

If you later add server-side validation — and you should, once there is a server — keep these client-side attributes. They are the fast feedback loop, not the security boundary.

<Checkbox id="contact-consent" name="consent" required class="mt-0.5" />
<Label for="contact-consent" size="sm" class="text-muted-foreground leading-5 font-normal">
  I'm happy for Develi to store what I send here so they can reply. See the{" "}
  <a href={navData.legalLinks.privacy.href} class="primary-focus text-foreground rounded-xs underline">
    privacy policy
  </a>.
</Label>

Two details:

The privacy link comes from navData.legalLinks.privacy.href, not a literal. That route was previously spelled by hand on two surfaces — here and the footer’s bottom bar — and one route typed twice is exactly the drift the config layer exists to eliminate. legalLinks is keyed rather than a list precisely because this call site needs the privacy page specifically; finding it in an array by matching its label would reintroduce the problem by the back door.

The row is items-start with mt-0.5 on the box, because this label wraps to two lines on a phone and the 16px box needs to sit on the first line’s optical centre rather than its top edge. The catalog’s own checkbox rows are items-center, which is correct there where every label is one word.

Replace “Develi” in that sentence with your own name, and make sure your privacy policy actually says what you do with submissions. See Configuration — the shipped legalData.json.ts is placeholder text.

The submit button

CtaButton — the same primitive as the header’s Contact button and every “Book a call” on the site, so the label roll, the trailing arrow and its size cannot drift from them. It is w-full at the narrow end and auto-width once there is room beside it.

SupportOptions

The two direct routes that are not the form: press and phone. Both targets come from siteData.contact, which is already the footer’s source for the same two facts.

Neither invents a route, and the reasoning is worth borrowing. The design reference’s second card schedules a meeting, and the theme ships no booking page — linking one would be a 404 dressed as a feature, which is worse than a nav row that 404s knowingly with a comment beside it. The phone is the honest equivalent and was already in config.

The display number is written for reading (+1 (000) 000-0000); the tel: href wants it written for dialling, so everything but the digits and a leading + is stripped. The footer derives the identical line.

Wiring a destination

Pick one. In rough order of how little has to change:

A form-to-email service — Formspree, Web3Forms, Basin, Netlify Forms and similar. Most need one attribute and no build change:

<form action="https://formspree.io/f/<your-id>" method="POST" class="flex flex-col gap-6">

This keeps the site fully static and keeps the no-JavaScript path working, which is the main reason to prefer it. Add a honeypot field and check what spam handling the provider gives you.

Your host’s native form handling. Netlify Forms wants data-netlify="true" and a hidden form-name input; Cloudflare Pages and others have equivalents. Same trade as above, tied to one host.

A server route. This is the bigger change: install an Astro adapter, set export const prerender = false; on src/pages/contact.astro, and post to an action or an API route that calls a mail provider. It gives you server-side validation and full control, at the cost of the theme no longer being adapter-free — every other route stays static, but the deploy target changes.

A third-party embed. Fastest to ship, and it discards the form you already have along with its native validation and no-JS path. Only worth it if you need a builder UI for non-technical editors.

Whichever you choose, three things are worth doing at the same time:

  • Add spam protection. A honeypot field and a submission time gate cost nothing and work without JavaScript. A CAPTCHA widget requires JavaScript and will make your form JS-only — a real trade-off, not a free win.
  • Give the reader a real result. A success message and a readable failure message. A form that silently does nothing on error is worse than one that says it could not send.
  • Say what happens next. “We reply within two working days” is worth more than a checkmark.

Verify

After wiring:

  • Submit with JavaScript disabled. If it stops working, you chose a JS-only path — decide deliberately whether that is acceptable.
  • Submit with a field empty and confirm the browser’s native message appears.
  • Tab through the whole form. Every control is reachable and every focus ring visible.
  • Check it in light and dark. The panel is a per-theme colour pair.
NEXT STEPImages & Assets