Headline Switch Hero
The second line of the headline is the navigation: KITCHENS / BATHROOMS / LOFTS at headline size, and choosing one swaps the treated photo plate, the copy and the price.
- Category
- Heroes
- Added
- 2026-09-25
- Deps
- none
- Updated
- 2026-09-25
Est. 2011 · Bristol · Gas Safe
Fitted for
Designed, built and fitted by the same three people, start to finish. No subcontractors, no surprises on the invoice.
Props
"use client";
import { useId, useRef, useState } from "react";
/**
* HeadlineSwitchHero — the trade you do is the headline, and the headline is
* the navigation.
*
* "Fitted for" and then, at the same size, KITCHENS / BATHROOMS / LOFTS. The
* chosen word is solid ink; the others are hollow outlines. Choosing one
* swaps the photo plate on the right, the line of copy and the "from" price in
* the plate's corner. Nothing changes on a timer: the visitor decides what
* they are looking at, and the page answers.
*
* IT IS A TABLIST. The words are real tabs (role="tab", aria-selected, roving
* tabindex) inside a tablist, and arrow keys move between them, so a keyboard
* user gets exactly what a mouse user gets. The plate is a tabpanel.
*
* ONE SATURATED FIELD. The plate colour is the only large area of colour on
* the page. Photographs are greyscaled and multiplied into it, so any three
* photos from the van read as one series. The button is ink, not accent — one
* field per screen.
*
* Needs Tailwind v4 (container queries are built in).
*/
export type Trade = {
/** The headline word. Keep it to one word; it is set very large. */
word: string;
/** One line under the headline. */
copy: string;
/** Photo for the plate. http(s), root-relative or data:image only. */
image?: string;
/** Mono microcopy in the plate's corner: "from £8,500 · 3–5 weeks". */
meta?: string;
/** Where the button goes for this trade. Falls back to ctaHref. */
href?: string;
};
const EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
const HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
function hex(v: string, fallback: string) {
return HEX.test(v) ? v : fallback;
}
function rgba(colour: string, alpha: number) {
let h = colour.slice(1);
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
const n = parseInt(h, 16);
return "rgba(" + ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + alpha + ")";
}
/**
* Relative paths, fragments, http(s), mailto and tel; anything else becomes
* "#". Judged on the URL the browser will see: a URL parser deletes tabs and
* newlines anywhere and reads "\" as "/", so "/\t/evil.com" and "/\evil.com"
* are both "//evil.com" — another site dressed as a local path.
*/
function safeHref(href: string) {
const h = href.replace(/[\t\n\r]/g, "").trim();
if (h.includes("\\")) return "#";
if (/^(\/(?!\/)|#|\?|\.{1,2}\/)/.test(h)) return h;
if (/^(https?:|mailto:|tel:)/i.test(h)) return h;
return "#";
}
/** Images: http(s), root-relative or data:image only — judged like safeHref. */
function safeSrc(src?: string) {
if (!src) return "";
const s = src.replace(/[\t\n\r]/g, "").trim();
if (s.includes("\\")) return "";
return /^(https?:\/\/|\/(?!\/)|data:image\/)/i.test(s) ? s : "";
}
// Static text only; no prop ever reaches this style element.
const CSS =
"@keyframes hsh-in{from{opacity:0}to{opacity:1}}" +
"@media (prefers-reduced-motion:reduce){[data-hsh-motion]{animation:none!important;transition:none!important}}";
export function HeadlineSwitchHero({
trades,
lead = "Fitted for",
field = "#c8452c",
paper = "#f4f2ee",
ink = "#111111",
ctaLabel = "Get a quote",
ctaHref = "#quote",
phone,
tag,
defaultIndex = 0,
onChange,
className = "",
}: {
trades: Trade[];
/** The first line of the headline. */
lead?: string;
/** The plate colour. Hex only. */
field?: string;
paper?: string;
ink?: string;
ctaLabel?: string;
ctaHref?: string;
/** Shown beside the button as a tel: link. */
phone?: string;
/** Mono microcopy top-left: "Est. 2011 · Bristol". */
tag?: string;
defaultIndex?: number;
onChange?: (trade: Trade, index: number) => void;
className?: string;
}) {
const id = useId().replace(/[^a-zA-Z0-9]/g, "");
const [active, setActive] = useState(Math.min(defaultIndex, Math.max(0, trades.length - 1)));
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
// Plate layers: each choice adds a layer that fades in over the last one.
// Only the last two are kept. Render-time adjustment, not an effect.
const [layers, setLayers] = useState([{ index: active, n: 0 }]);
const [seen, setSeen] = useState(active);
if (active !== seen) {
setSeen(active);
setLayers((l) => [...l.slice(-1), { index: active, n: (l[l.length - 1]?.n ?? 0) + 1 }]);
}
const plate = hex(field, "#c8452c");
const pa = hex(paper, "#f4f2ee");
const ik = hex(ink, "#111111");
const trade = trades[active];
function choose(i: number) {
if (i === active || !trades[i]) return;
setActive(i);
onChange?.(trades[i], i);
}
function onKeyDown(e: React.KeyboardEvent) {
const n = trades.length;
let j = active;
if (e.key === "ArrowRight" || e.key === "ArrowDown") j = (active + 1) % n;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") j = (active - 1 + n) % n;
else if (e.key === "Home") j = 0;
else if (e.key === "End") j = n - 1;
else return;
e.preventDefault();
choose(j);
tabs.current[j]?.focus();
}
return (
<section
className={"@container relative h-full overflow-hidden font-sans " + className}
style={{ background: pa, color: ik }}
>
<style>{CSS}</style>
{/* A container query answers to an ANCESTOR container, so the layout that
switches at @3xl lives on this inner box, never on the section itself. */}
<div className="flex h-full flex-col @3xl:flex-row">
{/* The plate. On a phone it is a band across the top; wide, it is the right third. */}
<div
id={id + "-panel"}
role="tabpanel"
aria-labelledby={id + "-tab-" + active}
className="relative order-first h-[38%] shrink-0 @3xl:order-last @3xl:h-auto @3xl:w-[42%]"
style={{ background: plate }}
>
{layers.map((layer) => {
const t = trades[layer.index];
const src = safeSrc(t?.image);
return (
<div key={layer.n} data-hsh-motion="" className="absolute inset-0" style={{ animation: "hsh-in 700ms " + EASE }}>
{src ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt=""
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
style={{ filter: "grayscale(1) contrast(1.25) brightness(1.05)", mixBlendMode: "multiply", opacity: 0.92 }}
/>
) : (
<div className="h-full w-full" style={{ background: rgba(ik, 0.18) }} />
)}
</div>
);
})}
{/* Plate corners: index top-right, the price and lead time bottom-left. */}
<span
aria-hidden="true"
className="absolute top-5 right-5 font-mono text-[11px] tracking-[0.16em] tabular-nums uppercase"
style={{ color: pa }}
>
{String(active + 1).padStart(2, "0")} / {String(trades.length).padStart(2, "0")}
</span>
{trade?.meta && (
<span
key={active}
data-hsh-motion=""
className="absolute bottom-5 left-5 font-mono text-[11px] tracking-[0.16em] uppercase"
style={{ color: pa, animation: "hsh-in 700ms " + EASE }}
>
{trade.meta}
</span>
)}
</div>
{/* The copy. */}
<div className="relative flex min-h-0 flex-1 flex-col justify-between px-6 pt-6 pb-6 @3xl:px-14 @3xl:pt-10 @3xl:pb-12">
<p className="font-mono text-[11px] tracking-[0.18em] uppercase" style={{ color: rgba(ik, 0.6) }}>
{tag}
</p>
<div className="my-4 min-w-0 @3xl:my-0">
<h1 className="font-black tracking-[-0.05em] uppercase">
<span className="block text-[44px] leading-[0.86] @3xl:text-[112px]">{lead}</span>
{/* The second line is the navigation. */}
<span
role="tablist"
aria-label="What we do"
onKeyDown={onKeyDown}
className="mt-1 flex flex-wrap gap-x-[0.28em] text-[44px] leading-[0.86] @3xl:mt-2 @3xl:text-[112px]"
>
{trades.map((t, i) => {
const on = i === active;
return (
<button
key={t.word + i}
ref={(el) => {
tabs.current[i] = el;
}}
id={id + "-tab-" + i}
type="button"
role="tab"
aria-selected={on}
aria-controls={id + "-panel"}
tabIndex={on ? 0 : -1}
onClick={() => choose(i)}
data-hsh-motion=""
className="cursor-pointer rounded-[2px] font-black uppercase outline-none focus-visible:ring-2 focus-visible:ring-offset-4"
style={{
// Hollow when not chosen: the same word, drawn as an outline.
color: on ? ik : "transparent",
WebkitTextStroke: on ? "0" : "1.5px " + rgba(ik, 0.7),
transition: "color 500ms " + EASE + ", -webkit-text-stroke-color 500ms " + EASE,
}}
>
{t.word}
</button>
);
})}
</span>
</h1>
</div>
<div className="flex flex-col gap-5 @3xl:flex-row @3xl:items-end @3xl:justify-between @3xl:gap-8">
<p
key={active}
data-hsh-motion=""
className="max-w-[36ch] text-[15px] leading-[1.45] @3xl:text-[18px]"
style={{ animation: "hsh-in 700ms " + EASE, color: rgba(ik, 0.82) }}
>
{trade?.copy}
</p>
<div className="flex shrink-0 items-center gap-5">
<a
href={safeHref(trade?.href ?? ctaHref)}
className="inline-flex h-11 items-center px-5 font-mono text-[12px] tracking-[0.14em] uppercase transition-transform duration-300 hover:-translate-y-px focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
style={{ background: ik, color: pa }}
>
{ctaLabel}
</a>
{phone && (
<a
href={"tel:" + phone.replace(/[^+\d]/g, "")}
className="font-mono text-[12px] tracking-[0.14em] uppercase underline-offset-4 hover:underline"
>
{phone}
</a>
)}
</div>
</div>
</div>
</div>
</section>
);
}
About this heroe
Most trade and salon heroes lead with an adjective and a stock photograph, and the visitor still has to hunt through the navigation to find out whether the business does the thing they need. Here the list of what the business does is the headline. "Fitted for" and then, at the same size, the trades: the chosen word is solid ink, the rest are hollow outlines, and clicking or arrowing to one swaps the photo plate, the line of copy and the from-price in the plate's corner. Nothing moves on a timer; the visitor decides. The words are real tabs with a roving tabindex, the plate is their panel, so keyboard users get the same hero as everyone else. The plate colour is the single saturated field on the page and the photographs are greyscaled and multiplied into it, which makes three unrelated snaps from the van read as one considered series. The button is ink rather than accent, deliberately. It is free because a good hero is where most small business sites go wrong first.
Curator’s note
A builder's hero usually says "Quality workmanship" over a stock photo of a spirit level. This one says what they do, in the biggest type on the page, and lets you pick.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Stacked Type Hero
sectionsOversized, tightly-stacked headline words that reveal in on mount — big type as sculpture.
Hours Poster Section
sectionsOpening hours set as a poster — days hanging off the edge, closed rows struck through, today banded in the one accent — with an open/closed line on the shop's clock.
Seam Reveal Hero
heroesA before-and-after hero with no handle: the seam is wherever your pointer is, eased like a curtain, and the colour arrives with the work.