Innovations
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-cutout

Where 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 \