heroes /
Flood + Cut-out Hero (Local Practice)
Saturated brand-colour field with a full-length cut-out subject bleeding off the right, a two-weight Playfair headline (light line then heavy white line), a utility strip of hours/address above the nav, a soft disc behind the subject and a curved wave edge into the next section. Built for local service businesses — clinics, practices, trades. Every colour is a prop.
Preview
Source
tsx
import { Clock, MapPin, Star } from "lucide-react";
export interface FloodCutoutHeroProps {
/** Text wordmark. Ignored when logoSrc is set. */
brand?: string;
/** Optional logo file. Knocked white by default so it survives the flood. */
logoSrc?: string;
logoAlt?: string;
/** Set false when the logo is already light and should not be inverted. */
invertLogo?: boolean;
navItems?: { label: string; href: string }[];
/** Right-hand pill in the nav — usually a phone number. */
navCta?: { label: string; href: string };
/** Two facts above the nav. The pattern earns its place for local businesses:
* "when are you open" and "where are you" precede every other question. */
utilityItems?: { icon?: "clock" | "pin"; label: string; strong?: string }[];
/** Lighter first line. */
headlineTop?: string;
/** Heavier second line, in white. */
headlineBold?: string;
sub?: string;
primaryCta?: { label: string; href: string };
secondaryCta?: { label: string; href: string };
/** Omit to hide the proof line entirely. Only ever pass a real, checkable
* figure — a rating is the one element a reader treats as independent proof. */
rating?: { score: string; count: string; source?: string };
/** Transparent PNG/WebP. It bleeds off the bottom of the flood, so the subject
* should be full-length with the feet near the frame edge. */
imageSrc?: string;
imageAlt?: string;
/** Shown small under the image. Use it when the photo is stock. */
imageNote?: string;
floodFrom?: string;
floodTo?: string;
inkColor?: string;
/** Colour of the section BELOW this one — the wave is filled with it so the
* flood reads as carved into rather than sat upon. */
nextSectionColor?: string;
starColor?: string;
}
/**
* Flood + cut-out hero.
*
* A saturated brand-colour field with a cut-out subject bleeding off the right,
* a two-weight serif headline, a utility strip of practical facts, and a curved
* bottom edge instead of a straight cut. Suits local service businesses —
* clinics, practices, trades — where the two questions before any other are
* "are you open" and "where are you".
*
* Every colour is a prop, so rebranding is four values.
*/
export default function FloodCutoutHero({
brand = "Northgate Dental",
logoSrc,
logoAlt = "Logo",
invertLogo = true,
navItems = [
{ label: "Services", href: "#services" },
{ label: "New patients", href: "#new-patients" },
{ label: "Contact", href: "#contact" },
],
navCta = { label: "(505) 555-0148", href: "tel:+15055550148" },
utilityItems = [
{ icon: "clock", strong: "Mon–Fri", label: "from 7:30am" },
{ icon: "pin", label: "312 Riverside Ave, Suite 4" },
],
headlineTop = "A reason to",
headlineBold = "stop putting it off.",
sub = "Preventive, cosmetic and emergency care in one practice — with sedation for the patients who have been putting this off for years.",
primaryCta = { label: "Book an appointment", href: "#book" },
secondaryCta = { label: "See our services", href: "#services" },
// Demo placeholder. It used to be 4.8 / 1,502 — High Country Dental's real,
// checkable Google figures — sitting under a fictional practice name, which is
// exactly how another business's numbers end up copy-pasted onto a live site.
rating = { score: "4.9", count: "212", source: "Google reviews" },
imageSrc = "/heroes/flood-cutout/family.webp",
imageAlt = "A family of four laughing together",
imageNote = "Illustrative photograph",
// Sampled off the reference, then deepened. The first pass used the cut-out's
// own studio backdrop (#A6B6C5 → #6E8397) and the whole band read as haze — the
// point of this hero is a SATURATED field, and a default that doesn't show it
// undersells the pattern in the gallery. Kept in the photo's cool hue family so
// the keyed subject still sits in the light it was shot in.
floodFrom = "#7FA6BC",
floodTo = "#2C4B5E",
inkColor = "#203040",
nextSectionColor = "#F6F3EC",
starColor = "#F3C98B",
}: FloodCutoutHeroProps) {
// Drawn for this component. A wave rather than a straight cut is the single
// biggest difference in feel between "a stack of coloured boxes" and a page.
const WAVE =
"M0,80 L0,40 C 96,12 176,56 296,32 C 416,8 496,54 616,36 C 736,18 816,60 936,40 C 1056,20 1136,46 1200,30 L1200,80 Z";
return (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;700&family=Inter:wght@400;500;600;700&display=swap"
/>
<style>{`
.fc-outline:hover { background: #fff; color: var(--fc-ink); }
`}</style>
<header
style={{
fontFamily: "'Inter', system-ui, sans-serif",
["--fc-ink" as string]: inkColor,
}}
>
{/* Utility strip */}
<div style={{ background: inkColor }} className="text-white/80 text-[13px]">
<div className="mx-auto max-w-[1180px] px-6 py-2.5 flex flex-wrap items-center justify-center sm:justify-end gap-x-8 gap-y-1">
{utilityItems.map((u, i) => (
<span key={i} className="inline-flex items-center gap-2">
{u.icon === "clock" && <Clock className="w-3.5 h-3.5 opacity-70 shrink-0" />}
{u.icon === "pin" && <MapPin className="w-3.5 h-3.5 opacity-70 shrink-0" />}
{u.strong && <b className="text-white font-semibold">{u.strong}</b>}
{u.label}
</span>
))}
</div>
</div>
{/* The flood */}
<div
className="relative overflow-hidden"
style={{ background: `linear-gradient(135deg, ${floodFrom} 0%, ${floodTo} 100%)` }}
>
{/* Soft disc behind the subject, so the cut-out has something to sit
against instead of floating in flat colour. */}
<span
aria-hidden="true"
className="pointer-events-none absolute rounded-full"
style={{
right: "-6%",
top: "-18%",
width: "62vw",
maxWidth: 900,
aspectRatio: "1",
background: "rgba(255,255,255,.13)",
}}
/>
<nav className="relative z-20">
<div className="mx-auto max-w-[1180px] px-6 py-5 flex flex-wrap items-center gap-5">
<a href="/" className="mr-auto block shrink-0">
{logoSrc ? (
<img
src={logoSrc}
alt={logoAlt}
className="w-44 h-auto"
// A dark mark keeps its type on a mid-tone flood but loses
// any pale detail, so it reads as half a logo. brightness(0)
// crushes it flat first so invert gives clean white.
style={invertLogo ? { filter: "brightness(0) invert(1)" } : undefined}
/>
) : (
<span
className="text-[1.4rem] font-medium text-white"
style={{ fontFamily: "'Playfair Display', Georgia, serif" }}
>
{brand}
</span>
)}
</a>
<ul className="flex gap-7 list-none order-3 w-full lg:order-none lg:w-auto">
{navItems.map((n) => (
<li key={n.href}>
<a
href={n.href}
className="text-white/90 hover:text-white text-[15px] font-medium no-underline border-b-2 border-transparent hover:border-white/50 pb-1 transition-colors"
>
{n.label}
</a>
</li>
))}
</ul>
{navCta && (
<a
href={navCta.href}
className="inline-flex items-center rounded-full bg-white px-5 py-3 text-[15px] font-semibold no-underline hover:opacity-90 transition-opacity"
style={{ color: inkColor }}
>
{navCta.label}
</a>
)}
</div>
</nav>
<div className="relative z-10 mx-auto max-w-[1180px] px-6">
<div className="grid items-end gap-8 lg:grid-cols-[1.05fr_.95fr] lg:min-h-[520px]">
<div className="pt-8 pb-12 lg:pb-24">
<span
className="block text-white/95 leading-[1.05] text-[clamp(1.9rem,3.6vw,2.9rem)]"
style={{ fontFamily: "'Playfair Display', Georgia, serif", fontWeight: 400 }}
>
{headlineTop}
</span>
<span
className="block text-white leading-[0.98] tracking-[-0.03em] text-[clamp(2.7rem,6.4vw,4.4rem)] mt-1.5"
style={{ fontFamily: "'Playfair Display', Georgia, serif", fontWeight: 700 }}
>
{headlineBold}
</span>
<p className="mt-6 max-w-[44ch] text-white/90 text-[19px] leading-relaxed font-light">
{sub}
</p>
<div className="mt-8 flex flex-wrap gap-3.5">
{primaryCta && (
<a
href={primaryCta.href}
className="inline-flex items-center rounded-full bg-white px-8 py-4 font-semibold no-underline hover:opacity-90 transition-opacity"
style={{ color: inkColor }}
>
{primaryCta.label}
</a>
)}
{secondaryCta && (
// Hover colour comes from a CSS variable, not a JS handler.
// Event handlers would force a client:* directive in Astro,
// and every hero in this registry is server-renderable.
<a
href={secondaryCta.href}
className="fc-outline inline-flex items-center rounded-full border-[1.5px] border-white/60 px-8 py-4 font-semibold text-white no-underline transition-colors"
>
{secondaryCta.label}
</a>
)}
</div>
{rating && (
<p className="mt-7 text-white/90">
<span aria-hidden="true" className="tracking-[.1em]" style={{ color: starColor }}>
{"★★★★★"}
</span>{" "}
<b className="text-white">{rating.score}</b> from{" "}
<b className="text-white">{rating.count}</b>{" "}
{rating.source ?? "reviews"}
</p>
)}
</div>
{/* The cut-out. It bleeds off the bottom of the flood — a person
standing IN the colour, not a picture placed on it. */}
<div className="relative self-end">
<img
src={imageSrc}
alt={imageAlt}
className="block ml-auto w-full max-w-[520px]"
loading="eager"
decoding="async"
/>
{imageNote && (
<span className="absolute right-0 bottom-2 text-[11px] text-white/70">
{imageNote}
</span>
)}
</div>
</div>
</div>
{/* Curved edge, filled with the colour of whatever follows. */}
<svg
viewBox="0 0 1200 80"
preserveAspectRatio="none"
aria-hidden="true"
className="relative z-10 block w-full h-[clamp(38px,5vw,74px)] -mb-px"
style={{ color: nextSectionColor }}
>
<path d={WAVE} fill="currentColor" />
</svg>
</div>
</header>
</>
);
} Claude Code Instructions
CLI Install
npx innovations add flood-cutoutWhere to use it
Use this when a local business needs presence and warmth at once — dental, medical, veterinary, trades, anything where a person in the picture beats a product shot. The utility strip earns its place for these: "are you open" and "where are you" are the two questions that precede every other one.
In Astro:
---
import FloodCutoutHero from '../components/innovations/heroes/flood-cutout';
---
<FloodCutoutHero />
In Next.js:
import FloodCutoutHero from '@/components/innovations/heroes/flood-cutout';
No client:* needed — server-renderable. Hover states are CSS, deliberately: an
onMouseEnter handler here would force hydration for a colour change.
REBRAND IN FOUR VALUES:
<FloodCutoutHero
floodFrom="#A6B6C5" // top-left of the gradient
floodTo="#6E8397" // bottom-right, deeper
inkColor="#203040" // utility strip, button text, logo knockout
nextSectionColor="#F6F3EC" // MUST match the section below, or the wave shows a seam
/>
Sample the two flood colours from the client's own logo rather than picking
them. A flat single colour across a whole hero reads as fog; the gradient is
what gives a muted brand palette enough life to flood with.
THE CUT-OUT:
imageSrc must be a TRANSPARENT png/webp of a full-length subject, feet near the
bottom edge — it bleeds off the bottom of the flood so the person stands IN the
colour rather than sitting in a box.
Generating one: shoot it on a seamless WHITE backdrop, then key it in three
passes, all of which are needed —
1. flood-fill INWARDS FROM THE BORDER, never a white threshold. A threshold
eats white shirts and socks; connectivity is what protects them.
2. erode the alpha ~2px. The fill leaves an anti-aliased fringe that is
invisible on white and a bright halo on colour.
3. clear enclosed pockets (the gap between two legs) by NEUTRALITY —
min>236 and channel spread<10 — not by brightness. Studio white has no
cast; a cream cardigan does.
Then composite it on the flood colour and LOOK at it before shipping.
THE WAVE:
nextSectionColor fills the curve, so the flood reads as carved into rather than
sat upon. Get it wrong and you get a visible band of the wrong colour. If the
client's logo contains a mark you can reuse — a range, an arc, a swoosh —
substitute its path for the WAVE const and the device stops being generic.
RATING LINE:
Omit the \