Innovations
heroes /

Cut-out + Blur Band Hero (Portfolio / Studio)

Full-height white stage holding one transparent cut-out photograph, with the copy along the bottom on page-white rather than over the picture. A masked backdrop-filter band ramps a blur in behind the lower copy. The photo fades up while scaling down on load, then zooms back in and descends with scroll. Ported from beautiful-possibilities.com/demo/freelancer; every measurement read off a real browser.

Preview

Source

tsx
"use client";

import { useEffect, useRef } from "react";

/**
 * Cut-out + Blur Band hero.
 *
 * A full-height stage holding one TRANSPARENT cut-out photograph, with the copy
 * along the bottom sitting on page-white rather than on the picture. A masked
 * backdrop-filter band finishes the job where the photo's lower frame is still
 * busy.
 *
 * Ported from beautiful-possibilities.com/demo/freelancer, where every number
 * below was read off a real browser rather than chosen. The comments that matter
 * are the ones recording things that already went wrong once — see the blur band
 * and the two motions.
 */
export interface CutoutBlurBandHeroProps {
  brand?: string;
  navLinks?: { label: string; href: string }[];
  navCta?: { label: string; href: string };
  /** MUST be a cut-out with real alpha. See the note on `.hero__media` below. */
  imageSrc?: string;
  imageAlt?: string;
  pill?: string;
  headline?: string;
  sub?: string;
  cta?: { label: string; href: string };
  /** Page ground. The copy sits on this, not on the photograph. */
  paper?: string;
  ink?: string;
  muted?: string;
  line?: string;
  /** The live dot in the pill. Omit `pill` and this never renders. */
  pillDot?: string;
}

export default function CutoutBlurBandHero({
  brand = "Studio Nine",
  navLinks = [
    { label: "How it works", href: "#process" },
    { label: "FAQs", href: "#faqs" },
  ],
  navCta = { label: "Email me", href: "#contact" },
  imageSrc = "/heroes/cutout-blur-band/subject.webp",
  imageAlt = "A person, cut out against the page",
  pill = "Booking work for the next quarter",
  headline = "Sites that keep working after the handover.",
  sub = "Design, build and the handover where you learn to run it yourself. Say something once and it does not have to be said again.",
  cta = { label: "Email me", href: "#contact" },
  paper = "#ffffff",
  ink = "#111111",
  muted = "#6B7078",
  line = "#E4E4E7",
  pillDot = "#35B37E",
}: CutoutBlurBandHeroProps) {
  const heroRef = useRef<HTMLElement | null>(null);
  const imgRef = useRef<HTMLImageElement | null>(null);

  // ENTRY: fade up while scaling DOWN, once, on load. One class flip, so a
  // failed script leaves the photo visible rather than stuck at opacity 0 — the
  // CSS owns the end state and this only decides WHEN.
  useEffect(() => {
    const hero = heroRef.current;
    if (!hero) return;
    const id = requestAnimationFrame(() =>
      requestAnimationFrame(() => hero.classList.add("is-in"))
    );
    return () => cancelAnimationFrame(id);
  }, []);

  // SCROLL: the photo zooms back IN and slides DOWN as the page moves — it
  // descends as it grows. Position-driven, not direction-driven, so scrolling
  // back up unwinds it exactly instead of replaying. Written to a CSS variable
  // so the transform stays on the compositor, and MULTIPLIED with --entry rather
  // than replacing it, so a page restored mid-scroll does not fight its own
  // entry animation.
  //
  // The two constants are measured, not chosen: sampling the reference across a
  // true 600px of scroll gives scale 1.000 → 1.086 with 139px of slide.
  // Extrapolated to a full hero height that is 1.144 and 231px.
  useEffect(() => {
    const img = imgRef.current;
    const hero = heroRef.current;
    if (!img || !hero) return;
    if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;

    const MAX_SCALE = 1.144;
    const MAX_SLIDE = 231;
    let queued = false;

    const tick = () => {
      const r = hero.getBoundingClientRect();
      if (r.bottom < 0) return;
      const t = Math.min(1, Math.max(0, -r.top / Math.max(1, r.height)));
      img.style.setProperty("--sz", (1 + (MAX_SCALE - 1) * t).toFixed(4));
      img.style.setProperty("--sy", (MAX_SLIDE * t).toFixed(1));
    };
    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 = `
    .cbb { --cbb-paper:${paper}; --cbb-ink:${ink}; --cbb-muted:${muted}; --cbb-line:${line}; }
    .cbb, .cbb * { box-sizing: border-box; }
    .cbb__nav { height: 68px; padding: 10px 40px; display: flex; align-items: center;
      gap: 28px; background: var(--cbb-paper); }
    .cbb__brand { font-weight: 500; letter-spacing: -.3px; color: var(--cbb-ink);
      text-decoration: none; margin-right: auto; }
    .cbb__links { display: flex; gap: 24px; }
    .cbb__links a { font-size: .92rem; color: var(--cbb-muted); text-decoration: none; }
    .cbb__links a:hover { color: var(--cbb-ink); }
    .cbb__btn { height: 48px; display: inline-flex; align-items: center; border-radius: 16px;
      padding: 12px 24px; background: var(--cbb-ink); color: #fff; text-decoration: none;
      font-size: .92rem; }

    /* Sized against the space UNDER the nav, not a flat 950px. The reference is
       950 tall on a 950 viewport, which puts its last line just above the fold;
       a pill plus a three-line headline needs the nav height back. */
    .cbb__hero { position: relative; height: calc(100vh - 68px); max-height: 950px;
      min-height: 620px; overflow: hidden; background: var(--cbb-paper); }

    /* THE PHOTO IS A TRANSPARENT CUT-OUT, and that is the whole reason the copy
       never fights it: the headline sits on page-white, not on a picture. If this
       image is swapped, swap it for another cut-out — a photo with a scene in it
       puts small grey type over a busy mid-tone and no amount of blur rescues
       that. WebP, not JPEG: JPEG has no alpha and would flatten the cut-out to a
       black rectangle.

       With object-fit COVER, narrowing the box CROPS TIGHTER — it does not scale
       the subject. Scaling needs contain plus a height. */
    .cbb__media { position: absolute; inset: 0; display: flex; justify-content: center; }
    .cbb__media img { width: min(940px, 74%); height: 100%; object-fit: cover;
      object-position: center top; display: block;
      transform: translateY(calc(var(--sy, 0) * 1px)) scale(calc(var(--entry, 1.06) * var(--sz, 1)));
      transform-origin: 50% 0%;
      opacity: 0;
      transition: opacity 1.1s cubic-bezier(.22,.61,.36,1), --entry 1.4s cubic-bezier(.22,.61,.36,1); }
    .cbb__hero.is-in .cbb__media img { --entry: 1; opacity: 1; }

    /* THE BLUR BAND — a backdrop-filter with a MASK, not a translucent overlay.
       400px tall at y=550 on a 950 stage, blur(20px), masked so the blur ramps in
       instead of starting at a hard edge. An overlay in a fixed colour would need
       repainting for every photo behind it; a masked backdrop-filter is the same
       element on any image.

       PREFIX FIRST, STANDARD LAST, and this is not cosmetic. Written the other
       way round, a CSS minifier collapsed the pair and kept only
       -webkit-backdrop-filter, which Chrome then ignored: the shipped page
       computed backdrop-filter as "none" and the blur simply did not exist. */
    .cbb__blur { position: absolute; left: 0; right: 0; top: 58%; bottom: 0;
      -webkit-backdrop-filter: blur(20px);
      backdrop-filter: blur(20px);
      -webkit-mask-image: linear-gradient(rgba(0,0,0,0) 20%, #000 75%);
      mask-image: linear-gradient(rgba(0,0,0,0) 20%, #000 75%);
      pointer-events: none; }

    .cbb__copy { position: absolute; left: 0; right: 0; bottom: 0; padding: 0 40px 64px; }
    .cbb__row { max-width: 1360px; margin: 0 auto; display: grid;
      grid-template-columns: 1fr 390px; gap: 48px; align-items: end; }
    .cbb__pill { display: inline-flex; align-items: center; gap: 8px; font-size: .84rem;
      color: var(--cbb-muted); border: 1px solid var(--cbb-line); border-radius: 999px;
      padding: 7px 14px; background: rgba(255,255,255,.7); margin: 0 0 18px; }
    .cbb__pill i { width: 8px; height: 8px; border-radius: 99px; background: ${pillDot};
      display: block; font-style: normal; }
    .cbb__hero h1 { font-size: 40px; font-weight: 500; line-height: 44px; letter-spacing: -1.2px;
      margin: 0; max-width: 18ch; color: var(--cbb-ink); }
    .cbb__sub { margin: 0 0 22px; font-size: .95rem; line-height: 1.55; color: #4A4F57; }

    /* Full-bleed on phones: at 74% of a 390px viewport the subject is a stripe. */
    @media (max-width: 760px) {
      .cbb__nav { padding: 10px 20px; gap: 16px; }
      .cbb__links { display: none; }
      .cbb__hero { height: 640px; }
      .cbb__media { inset: 22px 0 auto 0; height: 392px; }
      .cbb__media img { width: 100%; }
      .cbb__blur { top: 44%; }
      .cbb__copy { padding: 0 20px 34px; }
      .cbb__row { grid-template-columns: 1fr; gap: 20px; }
      .cbb__hero h1 { font-size: 30px; line-height: 34px; letter-spacing: -.7px; }
    }

    @media (prefers-reduced-motion: reduce) {
      .cbb__media img { opacity: 1; transition: none; transform: none; }
    }
  `;

  return (
    <div
      className="cbb"
      style={{ fontFamily: "'Instrument Sans', system-ui, sans-serif", background: paper }}
    >
      {/* Instrument Sans is the reference's own face, and the typeface is most of
          what makes this direction recognisable — a substitution leaves a hero
          that demonstrates a direction nobody chose. If you reuse this AS A
          STARTING POINT for a real brand, pick a face with more personality:
          Instrument Sans is a common choice and on original work that criticism
          lands. */}
      <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=Instrument+Sans:wght@400;500;600&display=swap"
      />
      {/* @property is what makes --entry animatable at all; without it the
          registration is missing and the scale-down entry snaps. */}
      <style>{`@property --entry { syntax: "<number>"; inherits: false; initial-value: 1.06; }` + css}</style>

      <header className="cbb__nav">
        <a className="cbb__brand" href="#top">{brand}</a>
        <nav className="cbb__links">
          {navLinks.map((n) => (
            <a key={n.href + n.label} href={n.href}>{n.label}</a>
          ))}
        </nav>
        {navCta && <a className="cbb__btn" href={navCta.href}>{navCta.label}</a>}
      </header>

      <section className="cbb__hero" id="top" ref={heroRef}>
        <div className="cbb__media">
          <img ref={imgRef} src={imageSrc} alt={imageAlt} />
        </div>
        <div className="cbb__blur" aria-hidden="true" />
        <div className="cbb__copy">
          <div className="cbb__row">
            <div>
              {/* A scope note, not a claim. The reference's "20+ projects
                  completed" is deliberately absent: a demo has no results to
                  report, and a count is the kind of thing a reader checks. */}
              {pill && <p className="cbb__pill"><i /> {pill}</p>}
              <h1>{headline}</h1>
            </div>
            <div>
              <p className="cbb__sub">{sub}</p>
              {cta && <a className="cbb__btn" href={cta.href}>{cta.label}</a>}
            </div>
          </div>
        </div>
      </section>
    </div>
  );
}
Claude Code Instructions

CLI Install

npx innovations add cutout-blur-band

Where to use it

Use this for a portfolio, a studio, a solo consultant, an agency — anywhere one person or one product IS the brand and the tone is calm and typographic. It is the opposite trade to a flooded colour hero: the page's own white is the backdrop and the photograph does the talking. In Astro: --- import CutoutBlurBandHero from '../components/innovations/heroes/cutout-blur-band'; --- <CutoutBlurBandHero client:load /> It needs client:load (or client:visible). Both motions are useEffect-driven and the photo starts at opacity 0 — server-rendered alone it fades in only once React mounts. THE IMAGE MUST BE A CUT-OUT WITH REAL ALPHA. This is the one thing that decides whether the design works. The headline and the small grey sub-line sit on page-white; drop in a full environmental shot and they land on a laptop, a desk, a wall, and no amount of blur rescues small grey type over a busy mid-tone. Export WebP, never JPEG — JPEG has no alpha and flattens the cut-out to a black rectangle. Rebrand with: brand, navLinks, navCta, imageSrc, imageAlt, pill, headline, sub, cta. Recolour with: paper, ink, muted, line, pillDot. The pill is a SCOPE NOTE, not a claim — "booking work for the next quarter", "memory that carries between conversations". The reference this came from puts "20+ projects completed" there. Do not. A count, a rating and a years-in-business figure are all things a reader treats as checkable, so they belong to a real business with real numbers or nowhere. Pass pill="" to drop it entirely. Two motions, deliberately separate and deliberately multiplied rather than shared: ENTRY is a one-shot transition on the is-in class, SCROLL is a CSS variable written by rAF. Multiplying means a page restored mid-scroll does not fight its own entry animation. Both constants (1.144 scale, 231px slide) are measured off the reference — change them only if the hero's proportions change. Two things that have already bitten and will again: · Keep -webkit-backdrop-filter BEFORE the standard backdrop-filter. Reversed, a CSS minifier collapses the pair to the webkit one, Chrome ignores it, and the blur band silently computes to none. · With object-fit cover, narrowing the image box crops tighter — it does not scale the subject. Scaling needs contain plus a height. The @property registration for --entry is required, not decorative: without it the custom property is not animatable and the scale-down entry snaps instead of easing.