Tell us about your trip.
Group bookings, repairs or a question about gear. A real person reads every message.
- hello@northwind.test
- Replies
- Within one working day
Headline and contact details beside a short native form with name, email, company and message. No JavaScript.
ConversionSection1 dependency Original, MIT
Group bookings, repairs or a question about gear. A real person reads every message.
Using Claude Code? Give it the agent prompt and skip these steps.
foundation.css holds the tokens every component reads.
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
--- ---
import ContactForm from "../components/viewership/contact-form/ContactForm.astro";
---
<ContactForm /> | Prop | Type | Default | Description |
|---|---|---|---|
title | string | · | Headline. |
accent | string | · | Closing words of the headline, drawn in the theme's accent style. |
body | string | · | Supporting paragraph. |
details | { label: string; value: string; href?: string }[] | · | Contact lines under the copy, such as email or reply time. |
action | string | · | Form handler URL. |
method | "post" | "get" | "post" | Form method. |
company | boolean | true | Show the optional company field. |
messagePlaceholder | string | · | Placeholder for the message box. |
submitLabel | string | "Send message" | Submit button text. |
note | string | · | Small line beside the submit button. |
as | "h1" | "h2" | "h2" | Heading element. |
---
/**
* ContactForm: headline and a few ways to reach you on one side, a short
* native form on the other. Stacks when narrow. Uses the browser's own
* validation (required, type="email") and needs no JavaScript; point
* `action` at your form handler.
*
* @example
* <ContactForm title="Tell us about" accent="your trip." action="/api/contact" details={[{ label: "Email", value: "hello@northwind.test", href: "mailto:hello@northwind.test" }]} />
*/
import Button from "../button/Button.astro";
interface Detail {
label: string;
value: string;
href?: string;
}
interface Props {
title: string;
accent?: string;
body?: string;
/** Contact lines under the copy: email, phone, hours, response time. */
details?: Detail[];
/** Form handler URL. */
action?: string;
method?: "post" | "get";
/** Show the optional company field. */
company?: boolean;
messagePlaceholder?: string;
submitLabel?: string;
/** Small line beside the submit button. */
note?: string;
as?: "h1" | "h2";
class?: string;
}
const {
title,
accent,
body,
details = [],
action,
method = "post",
company = true,
messagePlaceholder = "A few lines about what you need",
submitLabel = "Send message",
note,
as: Heading = "h2",
class: className,
} = Astro.props;
const id = `vs-cf-${Math.random().toString(36).slice(2, 8)}`;
---
<section class:list={["vs-contact-form", className]}>
<div class="vs-contact-form__inner">
<div class="vs-contact-form__copy">
<Heading class="vs-display vs-contact-form__title">{title}{accent && <> <em>{accent}</em></>}</Heading>
{body && <p class="vs-contact-form__body">{body}</p>}
{
details.length > 0 && (
<dl class="vs-contact-form__details">
{details.map((d) => (
<div>
<dt>{d.label}</dt>
<dd>{d.href ? <a href={d.href}>{d.value}</a> : d.value}</dd>
</div>
))}
</dl>
)
}
</div>
<form class="vs-contact-form__card" action={action} method={method}>
<div class="vs-contact-form__row">
<div class="vs-contact-form__field">
<label for={`${id}-name`}>Name</label>
<input id={`${id}-name`} name="name" type="text" autocomplete="name" required />
</div>
<div class="vs-contact-form__field">
<label for={`${id}-email`}>Work email</label>
<input id={`${id}-email`} name="email" type="email" autocomplete="email" required />
</div>
</div>
{
company && (
<div class="vs-contact-form__field">
<label for={`${id}-company`}>
Company <span>Optional</span>
</label>
<input id={`${id}-company`} name="company" type="text" autocomplete="organization" />
</div>
)
}
<div class="vs-contact-form__field">
<label for={`${id}-message`}>Message</label>
<textarea id={`${id}-message`} name="message" rows="4" placeholder={messagePlaceholder} required></textarea>
</div>
<div class="vs-contact-form__submit">
<Button type="submit" variant="primary">{submitLabel}</Button>
{note && <p>{note}</p>}
</div>
</form>
</div>
</section>
<style>
.vs-contact-form {
container-type: inline-size;
background: var(--vs-page);
color: var(--vs-ink);
font-family: var(--vs-font-sans);
font-stretch: var(--vs-font-stretch);
}
.vs-contact-form__inner {
display: grid;
gap: clamp(32px, 6cqi, 72px);
max-width: var(--vs-container);
margin-inline: auto;
padding: var(--vs-section-y) var(--vs-gutter);
}
.vs-contact-form__title {
font-size: clamp(2.25rem, 6cqi, 4rem);
}
.vs-contact-form__body {
max-width: 28rem;
margin: 1.25rem 0 0;
font-size: 1.0625rem;
line-height: 1.55;
color: var(--vs-ink-2);
}
.vs-contact-form__details {
display: grid;
gap: 18px;
margin: 2.25rem 0 0;
}
.vs-contact-form__details dt {
font-size: 0.8125rem;
color: var(--vs-ink-3);
}
.vs-contact-form__details dd {
margin: 4px 0 0;
font-size: 1.0625rem;
}
.vs-contact-form__details a {
color: inherit;
text-decoration: underline;
text-decoration-color: var(--vs-line-strong);
text-underline-offset: 4px;
transition: text-decoration-color var(--vs-duration-fast) ease-out;
}
.vs-contact-form__details a:hover {
text-decoration-color: var(--vs-accent);
}
.vs-contact-form__card {
display: grid;
gap: 18px;
align-self: start;
padding: clamp(20px, 4cqi, 32px);
border-radius: calc(var(--vs-radius-window) + 6px);
background: var(--vs-surface);
border: var(--vs-border-w) solid var(--vs-elev-ring);
box-shadow: var(--vs-pop-shadow, var(--vs-shadow-md));
}
.vs-contact-form__row {
display: grid;
gap: 18px;
}
.vs-contact-form__field {
display: grid;
gap: 8px;
}
.vs-contact-form__field label {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
font-weight: 500;
}
.vs-contact-form__field label span {
font-weight: 400;
color: var(--vs-ink-3);
}
.vs-contact-form input,
.vs-contact-form textarea {
width: 100%;
box-sizing: border-box;
padding: 12px 14px;
border: var(--vs-border-w) solid var(--vs-line);
border-radius: var(--vs-radius-control);
background: var(--vs-inset);
color: var(--vs-ink);
font: inherit;
font-size: 1rem;
transition:
border-color var(--vs-duration-fast) ease-out,
background-color var(--vs-duration-fast) ease-out;
}
.vs-contact-form textarea {
min-height: 120px;
resize: vertical;
line-height: 1.5;
}
.vs-contact-form input::placeholder,
.vs-contact-form textarea::placeholder {
color: var(--vs-ink-3);
}
.vs-contact-form input:hover,
.vs-contact-form textarea:hover {
border-color: var(--vs-line-strong);
}
.vs-contact-form input:focus-visible,
.vs-contact-form textarea:focus-visible {
outline: none;
background: var(--vs-surface);
box-shadow: var(--vs-focus-ring);
}
.vs-contact-form :is(input, textarea):user-invalid {
border-color: var(--vs-red);
}
.vs-contact-form__submit {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
margin-top: 4px;
}
.vs-contact-form__submit p {
margin: 0;
font-size: 0.8125rem;
color: var(--vs-ink-3);
}
@container (min-width: 560px) {
.vs-contact-form__row {
grid-template-columns: 1fr 1fr;
}
}
@container (min-width: 860px) {
.vs-contact-form__inner {
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
}
}
</style> Original, MIT.