Marketing and public web
Contact
A finished page under Kiln. Copy it and it is yours outright: unlike a brickwork component or shell, it never upgrades under semver.
Contact
Talk to a real person
Sales, partnerships, or a question the docs do not answer. We reply within one working day.
Send a message
Tell us who you are and what you need. We do not use this form for marketing lists.
Other ways to reach us
Email hello@northwind.example for sales, or support@northwind.example if you already have an account. Office hours are Monday to Friday, 09:00 to 17:00 BST.
Before you write
How fast do you reply?
Can I book a demo from here?
Scroll inside the frame to inspect the full page.
Paste into a static page. Link @bwui/css (and @bwui/js for behaviours).
<!DOCTYPE html>
<html lang="en-GB" data-bw-brand="northline">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Book a walkthrough | Northline</title>
<meta name="description" content="Tell us about your depots and a Northline operations lead will reply within one working day." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Source+Sans+3:wght@400;500;600;700&family=Source+Serif+4:opsz,wght@8..60,500;8..60,650;8..60,700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/product_templates/assets/brickwork.css" />
<script defer src="/static/product_templates/assets/behaviours.js"></script>
<style>
/* Northline brand pack (fictional): cool paper, teal accent, display serif.
Colour lives in --bw-* tokens only, so an L1 pack recolours this page. */
:root,
[data-bw-brand="northline"] {
--bw-color-surface: oklch(0.985 0.004 240);
--bw-color-fg: oklch(0.26 0.02 250);
--bw-color-border: oklch(0.90 0.01 240);
--bw-color-accent: oklch(0.42 0.05 230);
--bw-color-danger: oklch(0.55 0.18 25);
--bw-color-success: oklch(0.56 0.13 155);
--bw-color-warning: oklch(0.70 0.14 75);
--bw-color-fg-on-accent: oklch(0.99 0 0);
--bw-font-family-sans: "Source Sans 3", system-ui, sans-serif;
--bw-font-family-display: "Source Serif 4", Georgia, serif;
}
body {
margin: 0;
font-family: var(--bw-font-family-sans);
background: var(--bw-color-surface);
color: var(--bw-color-fg);
}
.nl-header {
display: flex;
align-items: center;
gap: var(--bw-space-5);
padding: var(--bw-space-4) var(--bw-space-6);
border-bottom: 1px solid var(--bw-color-border);
background: color-mix(in oklab, var(--bw-color-surface) 94%, white);
}
.nl-header__brand {
display: inline-flex;
align-items: center;
gap: var(--bw-space-2);
margin-right: auto;
font-weight: 700;
letter-spacing: -0.03em;
color: var(--bw-color-fg);
text-decoration: none;
}
.nl-header__mark {
width: 1.125rem;
height: 1.125rem;
border-radius: 5px;
background: var(--bw-color-accent);
}
.nl-header__nav {
display: flex;
align-items: center;
gap: var(--bw-space-5);
}
.nl-header__nav a {
color: var(--bw-color-fg-muted);
text-decoration: none;
font-size: var(--bw-font-size-sm);
font-weight: 500;
}
.nl-header__nav a:hover,
.nl-header__nav a[aria-current="page"] {
color: var(--bw-color-fg);
}
.nl-header__cta {
display: inline-flex;
align-items: center;
min-height: 2.5rem;
padding: 0 var(--bw-space-4);
border-radius: var(--bw-radius-md);
background: var(--bw-color-accent);
color: var(--bw-color-fg-on-accent);
font-size: var(--bw-font-size-sm);
font-weight: 600;
text-decoration: none;
}
.nl-contact {
display: grid;
gap: var(--bw-space-10);
max-inline-size: 72rem;
margin-inline: auto;
padding: clamp(2.5rem, 6vw, 5rem) clamp(1.25rem, 4vw, 3rem);
}
@media (min-width: 60rem) {
.nl-contact {
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
align-items: start;
gap: clamp(3rem, 6vw, 5.5rem);
}
}
.nl-eyebrow {
margin: 0 0 var(--bw-space-3);
font-size: var(--bw-font-size-xs);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--bw-color-accent);
}
.nl-contact__heading {
margin: 0 0 var(--bw-space-4);
max-inline-size: 13ch;
font-family: var(--bw-font-family-display);
font-weight: 650;
font-size: clamp(2.25rem, 4.6vw, 3.5rem);
letter-spacing: -0.04em;
line-height: 1.05;
}
.nl-contact__lede {
margin: 0 0 var(--bw-space-8);
max-inline-size: 40ch;
font-size: 1.125rem;
line-height: 1.5;
color: var(--bw-color-fg-muted);
}
.nl-steps {
display: grid;
gap: var(--bw-space-5);
margin: 0 0 var(--bw-space-8);
padding: 0;
list-style: none;
counter-reset: nl-step;
}
.nl-steps li {
position: relative;
padding-inline-start: 2.75rem;
counter-increment: nl-step;
}
.nl-steps li::before {
content: counter(nl-step);
position: absolute;
inset-block-start: 0.05rem;
inset-inline-start: 0;
display: grid;
place-items: center;
inline-size: 1.75rem;
block-size: 1.75rem;
border-radius: 999px;
background: color-mix(in oklab, var(--bw-color-accent) 12%, var(--bw-color-surface));
color: var(--bw-color-accent);
font-size: var(--bw-font-size-sm);
font-weight: 700;
}
.nl-steps strong {
display: block;
margin-bottom: var(--bw-space-1);
letter-spacing: -0.01em;
}
.nl-steps span {
color: var(--bw-color-fg-muted);
font-size: var(--bw-font-size-sm);
line-height: 1.5;
}
.nl-direct {
padding-top: var(--bw-space-6);
border-top: 1px solid var(--bw-color-border);
font-size: var(--bw-font-size-sm);
line-height: 1.6;
color: var(--bw-color-fg-muted);
}
.nl-direct p {
margin: 0 0 var(--bw-space-3);
}
.nl-direct strong {
color: var(--bw-color-fg);
}
.nl-direct a {
color: var(--bw-color-fg);
text-underline-offset: 0.2em;
}
.nl-panel {
padding: clamp(1.5rem, 3vw, 2.25rem);
border: 1px solid var(--bw-color-border);
border-radius: 1rem;
background: color-mix(in oklab, var(--bw-color-surface) 40%, white);
box-shadow: 0 1rem 2.5rem color-mix(in oklab, var(--bw-color-fg) 8%, transparent);
}
.nl-panel__title {
margin: 0 0 var(--bw-space-1);
font-family: var(--bw-font-family-display);
font-size: 1.5rem;
font-weight: 650;
letter-spacing: -0.02em;
}
.nl-panel__sub {
margin: 0 0 var(--bw-space-6);
color: var(--bw-color-fg-muted);
font-size: var(--bw-font-size-sm);
}
.nl-form .bw-field,
.nl-form .bw-form-errors {
max-inline-size: none;
margin-block-end: var(--bw-space-4);
}
.nl-form__pair {
display: grid;
gap: 0 var(--bw-space-4);
}
@media (min-width: 40rem) {
.nl-form__pair {
grid-template-columns: 1fr 1fr;
}
}
.nl-form .bw-form-errors__list a {
color: inherit;
text-underline-offset: 0.2em;
}
.nl-check {
display: flex;
align-items: flex-start;
gap: var(--bw-space-3);
font-size: var(--bw-font-size-sm);
line-height: 1.45;
}
.nl-check .bw-checkbox {
flex: none;
margin: 0.15rem 0 0;
}
.nl-form__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--bw-space-4);
margin-top: var(--bw-space-6);
}
.nl-form__fine {
margin: 0;
color: var(--bw-color-fg-muted);
font-size: var(--bw-font-size-xs);
line-height: 1.5;
max-inline-size: 30ch;
}
.nl-form__fine a {
color: inherit;
}
.nl-footer {
display: flex;
flex-wrap: wrap;
gap: var(--bw-space-4);
justify-content: space-between;
padding: var(--bw-space-6);
border-top: 1px solid var(--bw-color-border);
color: var(--bw-color-fg-muted);
font-size: var(--bw-font-size-sm);
}
.nl-footer p {
margin: 0;
}
.nl-footer a {
color: inherit;
}
@media (max-width: 48rem) {
.nl-header__nav {
display: none;
}
}
</style>
</head>
<body data-theme="light" data-bw-brand="northline">
<a class="bw-skip-link" href="#main">Skip to content</a>
<header class="nl-header">
<a class="nl-header__brand" href="/templates/homepage/full/">
<span class="nl-header__mark" aria-hidden="true"></span>
Northline
</a>
<nav class="nl-header__nav" aria-label="Primary">
<a href="/templates/homepage/full/">Product</a>
<a href="/templates/landing/full/">Why Northline</a>
<a href="/templates/contact/full/" aria-current="page">Contact</a>
</nav>
<a class="nl-header__cta" href="/templates/auth-signin/full/">Sign in</a>
</header>
<main id="main" class="nl-contact">
<section aria-labelledby="contact-heading">
<p class="nl-eyebrow">Book a walkthrough</p>
<h1 id="contact-heading" class="nl-contact__heading">See Northline on your own routes.</h1>
<p class="nl-contact__lede">
Tell us about your depots and a Northline operations lead will reply within one working day.
</p>
<ol class="nl-steps">
<li>
<strong>We read your note</strong>
<span>A person on the operations team, never an autoresponder, picks it up.</span>
</li>
<li>
<strong>A 30 minute walkthrough</strong>
<span>We load a sample of your routes and show the depot board with your own data.</span>
</li>
<li>
<strong>A plan you can take to your team</strong>
<span>Rollout steps, who needs access and what a first month looks like.</span>
</li>
</ol>
<div class="nl-direct">
<p><strong>Prefer to talk?</strong> Call 0113 496 0142, Monday to Friday, 8am to 6pm.</p>
<p><strong>Already a customer?</strong> Email <a href="mailto:support@northline.example">support@northline.example</a> and quote your depot code.</p>
<p>Northline Fleet Systems Ltd, 14 Wharf Lane, Leeds LS1 4AB.</p>
</div>
</section>
<section class="nl-panel" aria-labelledby="form-heading">
<h2 id="form-heading" class="nl-panel__title">Send us a message</h2>
<p class="nl-panel__sub">Fields marked <span class="bw-field__required" aria-hidden="true">*</span><span class="bw-visually-hidden">with an asterisk</span> are required.</p>
<form class="nl-form" action="#" method="post" novalidate data-nl-form>
<div class="bw-form-errors" role="alert" data-nl-summary>
<p class="bw-form-errors__title" data-nl-summary-title>Check 2 fields before sending</p>
<ul class="bw-form-errors__list">
<li data-for="email"><a href="#email">Enter a work email in the form name@company.com.</a></li>
<li data-for="message"><a href="#message">Tell us what you would like to cover.</a></li>
</ul>
</div>
<div class="nl-form__pair">
<div class="bw-field">
<label class="bw-field__label" for="name">Name <span class="bw-field__required" aria-hidden="true">*</span></label>
<input class="bw-input" id="name" name="name" autocomplete="name" value="Priya Raman" required aria-describedby="name-error" data-msg-required="Enter your name." />
<p class="bw-field__error" id="name-error" hidden></p>
</div>
<div class="bw-field">
<label class="bw-field__label" for="company">Company</label>
<input class="bw-input" id="company" name="company" autocomplete="organization" value="Harbour Freight" />
</div>
</div>
<div class="bw-field bw-field--invalid">
<label class="bw-field__label" for="email">Work email <span class="bw-field__required" aria-hidden="true">*</span></label>
<input
class="bw-input"
id="email"
name="email"
type="email"
autocomplete="email"
value="priya.raman@harbour"
required
aria-invalid="true"
aria-describedby="email-hint email-error"
data-msg-required="Enter your work email."
data-msg-format="Enter a work email in the form name@company.com."
/>
<p class="bw-field__hint" id="email-hint">We only use this to reply.</p>
<p class="bw-field__error" id="email-error">Enter a work email in the form name@company.com.</p>
</div>
<div class="bw-field">
<label class="bw-field__label" for="topic-floor">Topic</label>
<div class="bw-combobox" data-bw-combobox>
<select class="bw-input bw-combobox__floor" id="topic-floor" name="topic">
<option value="walkthrough" selected>Book a walkthrough</option>
<option value="pricing">Pricing and plans</option>
<option value="integration">Integrations</option>
<option value="support">Support for an existing depot</option>
</select>
<div class="bw-combobox__field" hidden>
<input class="bw-input bw-combobox__input" placeholder="Search topics" aria-autocomplete="list" />
<ul class="bw-combobox__listbox" role="listbox" id="topic-listbox">
<li class="bw-combobox__option" role="option" data-bw-value="walkthrough">Book a walkthrough</li>
<li class="bw-combobox__option" role="option" data-bw-value="pricing">Pricing and plans</li>
<li class="bw-combobox__option" role="option" data-bw-value="integration">Integrations</li>
<li class="bw-combobox__option" role="option" data-bw-value="support">Support for an existing depot</li>
<li class="bw-combobox__empty" hidden>No matches</li>
</ul>
</div>
</div>
</div>
<div class="bw-field bw-field--invalid">
<label class="bw-field__label" for="message">What would you like to cover? <span class="bw-field__required" aria-hidden="true">*</span></label>
<textarea
class="bw-input"
id="message"
name="message"
rows="5"
required
aria-invalid="true"
aria-describedby="message-error"
placeholder="For example: 4 depots, 120 drivers, and handoffs that still run on spreadsheets."
data-msg-required="Tell us what you would like to cover."
></textarea>
<p class="bw-field__error" id="message-error">Tell us what you would like to cover.</p>
</div>
<label class="nl-check">
<input class="bw-checkbox" type="checkbox" name="updates" />
<span>Send me the occasional note on new Northline features. No more than once a month.</span>
</label>
<div class="nl-form__actions">
<button class="bw-btn bw-btn--primary bw-btn--lg" type="submit">Send message</button>
<p class="nl-form__fine">By sending this you agree to our <a href="/templates/legal/full/">privacy notice</a>.</p>
</div>
</form>
</section>
</main>
<footer class="nl-footer">
<p>© 2026 Northline Fleet Systems Ltd</p>
<p>
<a href="/templates/homepage/full/">Product</a> ·
<a href="/templates/landing/full/">Why Northline</a> ·
<a href="/templates/legal/full/">Privacy</a>
</p>
</footer>
<script>
/* Inline form feedback: re-checks a field as it is edited and keeps the
summary in step. Server-rendered errors use the same markup. */
(function () {
var form = document.querySelector("form[data-nl-form]");
if (!form) return;
var summary = form.querySelector("[data-nl-summary]");
var title = form.querySelector("[data-nl-summary-title]");
var EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
var inputs = Array.prototype.slice.call(form.querySelectorAll("[data-msg-required]"));
function problem(input) {
var value = input.type === "checkbox" ? input.checked : input.value.trim();
if (input.required && !value) return "required";
if (input.type === "email" && value && !EMAIL.test(value)) return "format";
return "";
}
function paint(input, kind) {
var field = input.closest(".bw-field");
var error = field.querySelector(".bw-field__error");
var item = summary && summary.querySelector('[data-for="' + input.id + '"]');
var message = kind ? input.getAttribute("data-msg-" + kind) : "";
error.textContent = message;
error.hidden = !kind;
field.classList.toggle("bw-field--invalid", !!kind);
if (kind) input.setAttribute("aria-invalid", "true");
else input.removeAttribute("aria-invalid");
if (item) {
item.hidden = !kind;
if (kind) item.firstElementChild.textContent = message;
}
}
function sync() {
if (!summary) return;
var count = form.querySelectorAll('[aria-invalid="true"]').length;
summary.hidden = count === 0;
title.textContent = "Check " + count + (count === 1 ? " field" : " fields") + " before sending";
}
inputs.forEach(function (input) {
input.addEventListener("input", function () {
if (input.getAttribute("aria-invalid") === "true") {
paint(input, problem(input));
sync();
}
});
input.addEventListener("blur", function () {
if (input.value.trim() !== "") {
paint(input, problem(input));
sync();
}
});
});
form.addEventListener("submit", function (event) {
var first = null;
inputs.forEach(function (input) {
var kind = problem(input);
paint(input, kind);
if (kind && !first) first = input;
});
sync();
if (first) {
event.preventDefault();
first.focus();
}
});
})();
</script>
</body>
</html>
Named export from @bwui/react. Do not invent other page components.
import "@bwui/css";
import { ContactForm } from "@bwui/react";
export default function Page() {
return (
<ContactForm
brand="Your brand"
title="Talk to us"
/>
);
}
Copy into your project. This example is not on the template loader path.
{% extends "brickwork_marketing/shell/marketing.html" %}
{% comment %}
A public contact page: hero, enquiry form, and the states a real form needs.
COPY THIS FILE into your project and edit it. It is not on the template loader
path, so you cannot extend it (ADR-056).
What your view must supply:
form the enquiry form (any fields; bw_form renders them)
contact_state "ready" | "invalid" | "loading" | "error" | "success" | "empty"
faq_items [{question, answer}] (ready/invalid/loading only)
footer_groups [{heading, links:[{label, href}]}]
You own the <form> tag and the submit button. bw_form only renders fields.
States: contact_state is the page's real branch.
"ready": blank form, FAQ, side details. Empty fields are intentional: the
reader has not typed yet. Required markers come from the form field
definitions, not from inventing placeholder copy.
"invalid": bound form after a failed POST. bw_form renders field and
non-field errors inline; keep the same chrome and FAQ so the reader can
fix and resubmit without losing orientation.
"loading": submit in flight (htmx or a progressive-enhancement path). The
primary button mounts loading=True (spinner + aria-busy + disabled);
the fields stay visible and disabled via the form's own disabled attrs
when you set them in the view. Server-only POST/redirect flows skip this
branch: LOADING is for the async path.
"error": the mailer or CRM rejected the submission after validation. Show
bw_alert danger above a still-editable form so the reader can retry.
"success": thank-you confirmation; no form. Do not invent a second page
for this: swap the band in place so the URL can stay /contact/ after a
PRG redirect.
"empty": contact is temporarily closed (holiday, capacity). _empty_state
replaces the form band; header and footer stay so the reader can leave.
Accessibility: inherits shell/marketing.html's skip link and header/footer
landmarks; the current-page nav link carries aria-current="page"; the
composed hero renders the page's own <h1>. Form labels, errors and the
loading button's aria-busy come from bw_form / bw_button. Covered by the
archetype harness's full gate sweep (render, axe WCAG 2.2 AA, no
horizontal overflow, light/dark distinctness, skip-link first-tab-stop
with JS disabled) at every W0.1 breakpoint, both themes.
Responsive: no breakpoint switch of its own; inherits shell/marketing.html's
fluid header wrap and content-column cap. The form column and the side
details stack naturally inside .bw-section-stack.
{% endcomment %}
{% load brickwork_components brickwork_forms %}
{% block page_title %}{% if contact_state == "success" %}Message sent - Northwind{% elif contact_state == "empty" %}Contact closed - Northwind{% else %}Contact - Northwind{% endif %}{% endblock %}
{% block brand_wordmark %}Northwind{% endblock %}
{% block marketing_nav_region %}
{% include "brickwork_marketing/components/_mobile_nav_toggle.html" %}
<nav class="bw-site-header__nav" aria-label="Primary">
{% block marketing_nav %}
<a href="/features/">Features</a>
<a href="/pricing/">Pricing</a>
<a href="/contact/" aria-current="page">Contact</a>
<a href="/docs/">Docs</a>
{% endblock %}
</nav>
{% endblock %}
{% block marketing_actions %}
{% if request.user.is_authenticated %}
<a href="/app/">Go to app</a>
{% else %}
<a href="/accounts/login/">Sign in</a>
{% bw_button "Start free trial" variant="primary" href="/accounts/signup/" %}
{% endif %}
{% endblock %}
{% block content %}
{% if contact_state == "empty" %}
{% include "brickwork_marketing/components/_hero.html" with eyebrow="Contact" heading="We are not taking new enquiries this week" lede="The team is at capacity until Monday 20 October. Email support still answers existing customers." align="center" %}
{% include "brickwork/components/_empty_state.html" with heading="Contact closed until Monday" body="New sales and partnership messages will queue again from 09:00 BST on Monday. Existing customers can still reach support@northwind.example." icon="document" action_href="/docs/" action_label="Read the docs" %}
{% elif contact_state == "success" %}
{% include "brickwork_marketing/components/_hero.html" with eyebrow="Contact" heading="Message received" lede="Thanks. A real person reads every enquiry. We reply within one working day." align="center" %}
{% include "brickwork/components/_alert.html" with variant="success" title="We have your message" message="Check your inbox for a short confirmation. If nothing arrives, look in spam, then try again from this page." %}
{% include "brickwork_marketing/components/_cta.html" with heading="While you wait" body="Most questions are answered in the docs. The trial is still free if you would rather try the product first." primary_cta_label="Read the docs" primary_cta_href="/docs/" secondary_cta_label="Start free trial" secondary_cta_href="/accounts/signup/" %}
{% else %}
{% include "brickwork_marketing/components/_hero.html" with eyebrow="Contact" heading="Talk to a real person" lede="Sales, partnerships, or a question the docs do not answer. We reply within one working day." align="center" %}
<div class="bw-section-stack">
{% if contact_state == "error" %}
{% include "brickwork/components/_alert.html" with variant="danger" title="We could not send that" message="The message did not reach us. Check your connection and try again. If it keeps failing, email hello@northwind.example directly." %}
{% endif %}
{% if contact_state == "invalid" %}
{% include "brickwork/components/_alert.html" with variant="warning" title="Check the highlighted fields" message="Something in the form needs fixing before we can send it. The errors sit under each field." %}
{% endif %}
<h2>Send a message</h2>
<p>
Tell us who you are and what you need. We do not use this form for
marketing lists.
</p>
<form method="post" action="/contact/"{% if contact_state == "loading" %} aria-busy="true"{% endif %}>
{% csrf_token %}
{% bw_form form %}
<div class="bw-form__actions">
{% if contact_state == "loading" %}
{% bw_button "Sending…" type="submit" variant="primary" loading=True %}
{% else %}
{% bw_button "Send message" type="submit" variant="primary" %}
{% endif %}
</div>
</form>
<div class="bw-prose">
<h2>Other ways to reach us</h2>
<p>
Email <a href="mailto:hello@northwind.example">hello@northwind.example</a>
for sales, or
<a href="mailto:support@northwind.example">support@northwind.example</a>
if you already have an account. Office hours are Monday to Friday,
09:00 to 17:00 BST.
</p>
</div>
</div>
{% include "brickwork_marketing/components/_faq.html" with heading="Before you write" items=faq_items single_open=True %}
{% endif %}
{% endblock %}
{% block marketing_footer %}
{% include "brickwork_marketing/components/_marketing_footer_groups.html" with groups=footer_groups %}
{% endblock %}
{% block footer_legal %}
<p>Copyright 2026 Northwind Software Ltd. <a href="/privacy/">Privacy</a> <a href="/terms/">Terms</a></p>
{% endblock %}
Details
| Kind | Page example |
|---|---|
| Used in | Marketing and public web |
Composed from
| Template | Description |
|---|---|
brickwork/components/_alert.html |
A full-width banner for page-level status and errors. |
brickwork/components/_button.html |
A button or link styled as a button, in several variants. |
brickwork/components/_empty_state.html |
A placeholder for a list or panel with nothing to show yet. |
brickwork/forms/_form.html |
Not a catalogue component |
brickwork_marketing/components/_cta.html |
A call-to-action band with a heading and buttons. |
brickwork_marketing/components/_faq.html |
A list of question-and-answer pairs. |
brickwork_marketing/components/_hero.html |
A page-opening band with a heading, lede, and call to action. |
brickwork_marketing/components/_marketing_footer_groups.html |
Named columns of footer links for the marketing shell footer region. |
brickwork_marketing/components/_mobile_nav_toggle.html |
A package-owned disclosure that collapses marketing nav and actions below the large breakpoint. |
brickwork_marketing/shell/marketing.html |
The public marketing shell: header, nav, content, and footer. |