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-bandWhere 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.