Innovations
heroes /

Pastel Flood Hero (Wellness / Clinic)

Blush field with a cut-out subject bleeding off the TOP edge, a two-weight serif headline (small dark kicker over an oversized white display word), one pill CTA and sparse tone-on-tone discs. Copy staggers in at 450ms and the photograph carries a short scroll parallax. Built from a dental-tourism reference; every colour is a prop.

Preview

Source

tsx
"use client";

import { useEffect, useRef } from "react";

/**
 * Pastel Flood hero — blush field, two-weight serif headline, cut-out subject
 * bleeding off the TOP edge.
 *
 * Built from a reference screenshot of a dental-tourism landing page. The top
 * bleed is the structural idea and the reason this is not the flood-cutout hero
 * in a different colour: there, the subject stands on the baseline under a nav;
 * here the figures are cropped by the top of the frame and there is no nav above
 * them at all, which is what makes the field read as a window rather than a
 * banner.
 *
 * The other half of the idea is the headline split. A small dark serif line sits
 * directly above an enormous white one — same family, two weights, two colours.
 * The contrast between them is doing the work, so keep both or neither; an
 * oversized white word on its own has nothing to push against.
 */
export interface PastelFloodHeroProps {
  /** Optional overlay nav. Off by default — the reference has no header above
   *  the photo, and adding one crops the top bleed that defines the design. */
  brand?: string;
  navLinks?: { label: string; href: string }[];
  /** Small dark line above the display word. */
  kicker?: string;
  /** The display word. One or two words — this runs very large. */
  headline?: string;
  body?: string;
  cta?: { label: string; href: string };
  imageSrc?: string;
  imageAlt?: string;
  /** Generated or stock imagery is labelled, always. An unlabelled stranger
   *  under a practice's name reads as a claim about who they treat. Pass "" only
   *  when the photograph is genuinely the business's own. */
  imageCaption?: string;
  floodFrom?: string;
  floodTo?: string;
  /** Tone-on-tone decoration. Keep it close to the flood — the moment it
   *  contrasts it stops being depth and starts being clutter. */
  blobColor?: string;
  /** The kicker and the button label. A warm near-black, not pure black. */
  ink?: string;
  /** Bottom edge. "convex" bulges the flood DOWN in the middle (the friendlier
   *  form, and the default), "concave" scoops it up, "none" is a straight cut. */
  curve?: "convex" | "concave" | "none";
  curveHeight?: number;
  /** MUST match the background of whatever section follows, because the curve is
   *  drawn IN that colour — it is a bite taken out of the flood, not a shape
   *  laid on top of it. Get this wrong and you ship a coloured band across the
   *  seam that looks like a rendering bug. */
  nextSectionColor?: string;
}

export default function PastelFloodHero({
  brand,
  navLinks = [],
  kicker = "Time To",
  headline = "Smile!",
  body =
    "Enjoy your new dental offers and travel destinations. Skip the waiting list, choose your service or destination and start booking today.",
  cta = { label: "Start your dental travel", href: "#book" },
  imageSrc = "/heroes/pastel-flood/couple.webp",
  imageAlt = "A laughing couple, one riding piggyback on the other",
  imageCaption = "Illustrative photograph",
  floodFrom = "#F9B4C4",
  floodTo = "#F2909F",
  blobColor = "#FAC3CF",
  ink = "#2E2A2B",
  curve = "convex",
  curveHeight = 88,
  nextSectionColor = "#FFFFFF",
}: PastelFloodHeroProps) {
  const rootRef = useRef<HTMLElement | null>(null);
  const photoRef = useRef<HTMLDivElement | null>(null);

  // ENTRY. One class flip; the CSS owns the end state, so a script that never
  // runs leaves everything visible rather than stuck at opacity 0.
  useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    const id = requestAnimationFrame(() =>
      requestAnimationFrame(() => el.classList.add("is-in"))
    );
    return () => cancelAnimationFrame(id);
  }, []);

  // SCROLL PARALLAX on the photograph. Subtle means short DISTANCE, not long
  // duration — 46px across the whole hero, which registers as depth without
  // anyone noticing a thing is moving. Written to a CSS variable so the
  // transform stays on the compositor.
  useEffect(() => {
    const el = rootRef.current;
    const photo = photoRef.current;
    if (!el || !photo) return;
    if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;

    const MAX_SHIFT = 46;
    let queued = false;
    const tick = () => {
      const r = el.getBoundingClientRect();
      if (r.bottom < 0 || r.top > window.innerHeight) return;
      const t = Math.min(1, Math.max(0, -r.top / Math.max(1, r.height)));
      photo.style.setProperty("--pf-shift", (t * MAX_SHIFT).toFixed(1) + "px");
    };
    const onScroll = () => {
      if (queued) return;
      queued = true;
      requestAnimationFrame(() => {
        queued = false;
        tick();
      });
    };
    tick();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
    };
  }, []);

  const css = `
    .pf { --pf-ink: ${ink}; --pf-blob: ${blobColor};
      position: relative; overflow: hidden; isolation: isolate;
      min-height: clamp(600px, 92vh, 900px);
      background: radial-gradient(120% 100% at 18% 8%, ${floodFrom} 0%, ${floodTo} 100%);
      font-family: 'Manrope', system-ui, sans-serif; }
    .pf, .pf * { box-sizing: border-box; }

    /* Tone-on-tone decoration, deliberately sparse. The reference carries a big
       soft disc behind the subject and a couple of small marks; a field of them
       reads as confetti and fights the photograph. */
    .pf__blob { position: absolute; border-radius: 50%; background: var(--pf-blob);
      opacity: .55; pointer-events: none; z-index: 0; }
    .pf__blob--main { width: min(62vw, 760px); aspect-ratio: 1; right: -8vw; top: -10%; opacity: .45; }
    .pf__blob--sm  { width: 120px; aspect-ratio: 1; left: 6%; bottom: 12%; opacity: .35; }
    /* Sits CLEAR of the photograph on purpose: at right:7% it was completely
       behind it, which is dead markup rather than decoration. */
    .pf__ring { position: absolute; width: 84px; aspect-ratio: 1; border-radius: 50%;
      border: 1.5px solid var(--pf-blob); right: 38%; bottom: 11%; opacity: .6;
      pointer-events: none; z-index: 1; }

    .pf__nav { position: relative; z-index: 4; display: flex; align-items: center;
      gap: 28px; padding: 26px clamp(20px, 5vw, 64px) 0; }
    .pf__brand { font-family: 'Playfair Display', Georgia, serif; font-size: 1.4rem;
      font-weight: 700; color: #fff; text-decoration: none; margin-right: auto; }
    .pf__nav a:not(.pf__brand) { color: rgba(255,255,255,.9); text-decoration: none;
      font-size: .93rem; font-weight: 600; }
    .pf__nav a:not(.pf__brand):hover { color: #fff; }

    .pf__grid { position: relative; z-index: 3; max-width: 1280px; margin: 0 auto;
      padding: clamp(48px, 8vh, 96px) clamp(20px, 5vw, 64px) clamp(56px, 9vh, 104px);
      display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
      align-items: center; gap: 32px; min-height: inherit; }

    /* THE HEADLINE SPLIT. Same family, two weights, two colours, and the second
       line is set tight enough that it reads as one mark rather than as text.
       line-height stays at .92 rather than dropping to the .82 that looks right
       in a mockup: below ~.9 the BOX becomes shorter than the glyphs, and the
       descender of a 'g' or the tail of a '!' starts printing over whatever sits
       underneath it. */
    .pf__kicker { font-family: 'Playfair Display', Georgia, serif; margin: 0;
      font-size: clamp(1.6rem, 3.2vw, 2.7rem); font-weight: 400; color: var(--pf-ink);
      letter-spacing: -.01em; }
    .pf__h { font-family: 'Playfair Display', Georgia, serif; margin: -.06em 0 0;
      font-size: clamp(3.4rem, 8.6vw, 7rem); font-weight: 700; line-height: .92;
      letter-spacing: -.02em; color: #fff; }
    .pf__body { margin: 22px 0 0; max-width: 44ch; font-size: 1rem; line-height: 1.6;
      color: rgba(255,255,255,.9); }
    .pf__cta { display: inline-flex; align-items: center; gap: 10px; margin-top: 30px;
      background: #fff; color: var(--pf-ink); text-decoration: none;
      border-radius: 999px; padding: 15px 26px; font-size: .93rem; font-weight: 700;
      box-shadow: 0 10px 26px rgba(140, 40, 60, .16);
      transition: transform .25s ease, box-shadow .25s ease; }
    .pf__cta:hover { transform: translateY(-2px); box-shadow: 0 14px 30px rgba(140, 40, 60, .22); }
    .pf__cta span { transition: transform .25s ease; }
    .pf__cta:hover span { transform: translateX(3px); }

    /* THE TOP BLEED. The photo is taller than the frame and hangs off BOTH
       edges: the frame takes a sliver off the top of the hair and a lot off the
       legs, leaving head-to-mid-thigh in view. That asymmetry is the whole
       trick — cropping only the top leaves the figures standing on the baseline,
       which is a different (and much more ordinary) hero.
       The two numbers are a pair. --pf-photo-h over 100% is how far the image
       exceeds the frame; --pf-photo-drop is how much of that excess is spent at
       the BOTTOM. Top crop is therefore (h - 100% - drop), and it wants to stay
       around 4%: at 12% it was slicing their faces off. */
    .pf__photo { --pf-photo-h: 150%; --pf-photo-drop: 46%;
      position: absolute; right: clamp(0px, 3vw, 60px);
      bottom: calc(var(--pf-photo-drop) * -1);
      height: var(--pf-photo-h); z-index: 2; pointer-events: none;
      transform: translateY(var(--pf-shift, 0px));
      will-change: transform; }
    .pf__photo img { height: 100%; width: auto; display: block;
      filter: drop-shadow(0 24px 40px rgba(120, 30, 50, .18)); }
    /* THE CURVED BOTTOM sits ABOVE the photograph on purpose (z-index 5 against
       the photo's 2). The figures are cut by the curve rather than by a straight
       section edge, so they read as standing behind it instead of being pasted
       on top — which is the entire reason to curve an edge rather than just
       rounding a corner. */
    .pf__curve { position: absolute; left: 0; right: 0; bottom: -1px; z-index: 5;
      line-height: 0; pointer-events: none; }
    .pf__curve svg { display: block; width: 100%; height: ${curveHeight}px; }

    .pf__cap { position: absolute; right: 10px; bottom: ${curve === "none" ? 8 : curveHeight + 8}px;
      z-index: 6; font-size: .66rem; letter-spacing: .04em; color: rgba(255,255,255,.72); }

    /* ── ENTRY ANIMATION ──────────────────────────────────────────────────
       450ms with a 90ms stagger. Measured against Oz's own calibration: ~1s
       reveals read as slow, 400–500ms does not. */
    @media (prefers-reduced-motion: no-preference) {
      .pf__kicker, .pf__h, .pf__body, .pf__cta, .pf__photo {
        opacity: 0; transform: translateY(16px);
        transition: opacity .45s cubic-bezier(.22,.61,.36,1),
                    transform .45s cubic-bezier(.22,.61,.36,1); }
      .pf__photo { transform: translateY(26px) scale(1.03); }
      .pf.is-in .pf__kicker { opacity: 1; transform: none; transition-delay: 40ms; }
      .pf.is-in .pf__h      { opacity: 1; transform: none; transition-delay: 130ms; }
      .pf.is-in .pf__body   { opacity: 1; transform: none; transition-delay: 220ms; }
      .pf.is-in .pf__cta    { opacity: 1; transform: none; transition-delay: 310ms; }
      /* The photo hands off from a transitioned transform to a scroll-driven
         one. They are the SAME property, so the transition has to be cleared
         once it has played or the parallax fights it every frame. */
      .pf.is-in .pf__photo  { opacity: 1; transform: translateY(var(--pf-shift, 0px));
                              transition-delay: 90ms; transition-duration: .5s; }
      .pf.is-in .pf__photo  { transition-property: opacity; }
      .pf__blob--main { animation: pf-float 22s ease-in-out infinite; }
      .pf__blob--sm { animation: pf-float 18s ease-in-out infinite reverse; }
    }
    @keyframes pf-float {
      0%, 100% { transform: translate3d(0, 0, 0); }
      50%      { transform: translate3d(0, 18px, 0); }
    }

    @media (max-width: 880px) {
      .pf { min-height: 0; }
      .pf__grid { grid-template-columns: 1fr; padding-bottom: 0; gap: 0; }
      .pf__body { max-width: none; }
      /* Out of absolute positioning entirely on phones: at 112% of a short
         viewport the top bleed eats both faces, and a hero whose photograph is
         two torsos is worse than one with no crop at all. */
      .pf__photo { position: relative; right: auto; bottom: auto; height: auto;
        width: min(100%, 300px); margin: 28px auto -1px; }
      .pf__photo img { width: 100%; height: auto; }
      .pf__cap { position: static; display: block; text-align: center;
        margin-top: 6px; }
      .pf__blob--main { right: -30vw; top: 30%; }
      .pf__ring { display: none; }
    }
  `;

  return (
    <section className="pf" ref={rootRef}>
      <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;700&family=Manrope:wght@400;600;700&display=swap"
      />
      <style>{css}</style>

      <div className="pf__blob pf__blob--main" aria-hidden="true" />
      <div className="pf__blob pf__blob--sm" aria-hidden="true" />
      <div className="pf__ring" aria-hidden="true" />

      {brand && (
        <header className="pf__nav">
          <a className="pf__brand" href="#top">{brand}</a>
          {navLinks.map((n) => (
            <a key={n.href + n.label} href={n.href}>{n.label}</a>
          ))}
        </header>
      )}

      <div className="pf__grid">
        <div>
          {kicker && <p className="pf__kicker">{kicker}</p>}
          <h1 className="pf__h">{headline}</h1>
          {body && <p className="pf__body">{body}</p>}
          {cta && (
            <a className="pf__cta" href={cta.href}>
              {cta.label} <span aria-hidden="true">&rarr;</span>
            </a>
          )}
        </div>
        <div />
      </div>

      <div className="pf__photo" ref={photoRef}>
        <img src={imageSrc} alt={imageAlt} />
      </div>

      {curve !== "none" && (
        <div className="pf__curve" aria-hidden="true">
          {/* preserveAspectRatio="none" is what lets one path stretch to any
              width without the curve getting deeper on a wide screen. */}
          <svg viewBox="0 0 1200 100" preserveAspectRatio="none">
            <path
              fill={nextSectionColor}
              d={
                curve === "convex"
                  ? "M0,0 Q600,100 1200,0 L1200,100 L0,100 Z"
                  : "M0,100 Q600,0 1200,100 L1200,100 L0,100 Z"
              }
            />
          </svg>
        </div>
      )}

      {imageCaption && <p className="pf__cap">{imageCaption}</p>}
    </section>
  );
}
Claude Code Instructions

CLI Install

npx innovations add pastel-flood

Where to use it

Use this for wellness, dental, cosmetic, travel, spa — anywhere the promise is a feeling rather than a specification, and there is a photograph of happy people to carry it. It is warm and soft by construction; on a trade or a professional service it will read as the wrong industry. In Astro: --- import PastelFloodHero from '../components/innovations/heroes/pastel-flood'; --- <PastelFloodHero client:load /> It needs client:load (or client:visible). The entry stagger and the scroll parallax are both effect-driven. TWO THINGS ARE THE DESIGN, and losing either turns this into an ordinary coloured banner: 1. THE TOP BLEED. The photo is taller than the frame and pinned to its bottom, so the frame crops the subject's heads. That is why there is no nav above it — brand and navLinks exist as props but default to OFF, because a header eats exactly the band the crop needs. Turn the nav on only if you also reduce the photo height, and look at it before you ship it. 2. THE HEADLINE SPLIT. A small dark serif kicker sits directly above an enormous white display word, same family, two weights, two colours. The contrast is doing the work. Keep both or neither — an oversized white word alone has nothing to push against. Rebrand with: kicker, headline, body, cta, imageSrc, imageAlt. Recolour with: floodFrom, floodTo, blobColor, ink. THE BOTTOM EDGE IS A CURVE, drawn IN the next section's colour — it is a bite taken out of the flood, not a shape laid over it. So nextSectionColor MUST match whatever section follows, or you ship a coloured band across the seam that reads as a rendering bug. curve="convex" bulges the flood down in the middle (default), "concave" scoops it up, "none" is a straight cut; curveHeight is in px. The curve deliberately paints ABOVE the photograph, so the figures are cut by it and read as standing behind it — that is the point of curving an edge rather than rounding a corner. Keep blobColor CLOSE to the flood — the moment the decoration contrasts it stops being depth and becomes clutter. THE PHOTO MUST BE A CUT-OUT WITH REAL ALPHA, and it wants a subject whose energy survives being cropped at the forehead — a laugh, a gesture, two people. A single calm portrait cropped at the top just looks like a mistake. scripts/key-cutout.mjs in this repo prepares one: prompt the image model for a flat magenta studio ground (asking for "transparent" makes it render a PICTURE of a checkerboard), then key it. It also handles a white ground and passes an already-transparent PNG straight through (mode auto). Three passes and all three are needed — border flood fill, enclosed-pocket clearing (the gaps between strands of hair, which the border fill cannot reach and which otherwise survive as bright magenta patches inside the figure), and edge despill. Leave imageCaption set to "Illustrative photograph" for any generated or stock image. Pass "" only when the photograph is genuinely the client's own — an unlabelled stranger under a practice's name reads as a claim about who they treat. The headline's line-height stays at .92 and should not be dropped to the .82 that looks right in a mockup: below about .9 the line BOX becomes shorter than the glyphs, and a descender or the tail of an exclamation mark starts printing over whatever sits beneath it. On the entry animation, note the photograph hands off from a transitioned transform to a scroll-driven one on the SAME property. The transition is narrowed to opacity once it has played, because otherwise the parallax and the entry fight each other every frame.