heroes /
Layered Descent Hero (Pinned Parallax Scene)
A tall container with a pinned 100vh stage inside it: the stage holds still while many viewports of scroll go past, so it reads as the camera descending through a scene rather than the page moving up. Parallax planes recede at different rates off one progress number, an optional two-term riser carries a foreground plate and its ground up behind it, and copy beats fade in and out across scroll ranges. Click-to-advance arrow included. Ported from beautiful-possibilities.com/reports/preview-soiltoolbox-site-v6.
Preview
Source
tsx
"use client";
import { useEffect, useRef } from "react";
/**
* Layered Descent hero — a pinned stage the camera travels DOWN through.
*
* A tall container with a position:sticky 100vh stage inside it. The stage holds
* still while many viewports of scroll go past, which is what makes it read as
* the camera descending rather than the page moving up. Parallax planes recede
* at different rates off ONE progress number, and copy beats fade in and out
* across scroll ranges.
*
* Ported from beautiful-possibilities.com/reports/preview-soiltoolbox-site-v6,
* generalised: the planes, the riser and the beats are all data now, so the
* mechanism is reusable and the farm art is only the demo.
*
* ── THE FAILURE MODE IS INVISIBLE CONTENT, SO THE STATIC STATE IS THE DEFAULT.
* Every rule that moves anything lives under `.ld.is-motion`, and that class is
* added by the effect below and nowhere else. With no JS, a thrown error, or
* prefers-reduced-motion, the class is never applied and the section renders as
* a plain readable stack — the scene as a still, then the copy. NOTHING starts
* at opacity 0 in that state. This is not defensive habit; the page this came
* from paid for the lesson once with a hero nobody could read.
*/
export interface DescentPlane {
src: string;
/** How far this plane rises across the descent, in vh. Bigger = nearer the
* camera. Different multipliers off one number IS the parallax. */
travelVh: number;
/** Optional horizontal drift, in px, for a plane rendered as a seamless
* 3-copy track (clouds). Omit for a normal plane. */
driftPx?: number;
/** CSS `object-position` / sizing overrides for planes that are not
* full-bleed from the top. */
bottom?: string;
height?: string;
}
export interface DescentBeat {
/** Scroll-progress range, 0→1 across the whole container, that this copy is
* visible for. */
from: number;
to: number;
eyebrow?: string;
headline?: string;
body?: string;
/** Rendered under the body. Two at most — this is a hero, not a page. */
ctas?: { label: string; href: string; solid?: boolean }[];
/** Where the beat sits in the frame. */
place?: "center" | "bottom";
}
export interface LayeredDescentHeroProps {
/** Total scroll length of the pinned stage, in vh. The beat ranges are
* fractions of THIS, so if you lengthen it, scale the ranges rather than
* nudging them or the opening slows to a crawl. */
descentVh?: number;
/** Narrower viewports travel less and stay readable. */
descentVhMobile?: number;
planes?: DescentPlane[];
/** The optional two-term travelling unit: a foreground plate that rises to
* fill the frame, then carries its own ground up behind it. Welded into ONE
* box on purpose — the geometry then enforces the order (ground can never
* overtake the plate) that timing alone only approximates. */
riser?: { src: string; groundSrc?: string } | null;
beats?: DescentBeat[];
/** A click-to-advance arrow that scrolls to the middle of the next beat. It
* scrolls; it does NOT drive its own state — everything stays a function of
* scroll position, so a click and a trackpad flick land in the same place. */
showAdvance?: boolean;
background?: string;
ink?: string;
accent?: string;
}
const DEMO_PLANES: DescentPlane[] = [
{ src: "/heroes/layered-descent/sky.jpg", travelVh: 9 },
{ src: "/heroes/layered-descent/clouds-far.webp", travelVh: 12, driftPx: 120 },
{ src: "/heroes/layered-descent/clouds-near.webp", travelVh: 15, driftPx: 200 },
{ src: "/heroes/layered-descent/mountains.webp", travelVh: 20 },
{ src: "/heroes/layered-descent/hills.webp", travelVh: 32 },
{ src: "/heroes/layered-descent/crop.webp", travelVh: 39, bottom: "0" },
];
const DEMO_BEATS: DescentBeat[] = [
{
from: -1,
to: 0.035,
place: "center",
headline: "Regenerate your soil.\nGrow healthier crops.",
body: "Everything you need to build healthier, more productive soil — from biology and biostimulants to amendments and nutrition.",
ctas: [
{ label: "See how it works", href: "#how", solid: true },
{ label: "Talk to us", href: "#contact" },
],
},
{
from: 0.112,
to: 0.28,
place: "bottom",
body: "Whatever the crop does above ground, it was settled down here first.",
},
{
from: 0.36,
to: 0.66,
place: "center",
eyebrow: "Below the line",
headline: "The root zone is the whole argument.",
body: "Structure, biology and nutrition, in the order the plant actually needs them.",
},
// The descent needs an ENDING, not a fade-out. Beats that stop well before the
// container does leave thousands of pixels of dead scroll on a held frame,
// which reads as a page that has broken rather than one that has finished.
// Whatever you change descentVh to, keep the last beat running to ~1.
{
from: 0.7,
to: 1,
place: "center",
headline: "Start with a soil test.",
body: "We read it with you and build the programme around what is actually there.",
ctas: [{ label: "Book a soil test", href: "#contact", solid: true }],
},
];
export default function LayeredDescentHero({
descentVh = 1150,
descentVhMobile = 700,
planes = DEMO_PLANES,
riser = {
src: "/heroes/layered-descent/closeup.webp",
groundSrc: "/heroes/layered-descent/soil.webp",
},
beats = DEMO_BEATS,
showAdvance = true,
background = "#100C08",
ink = "#EAF1E6",
accent = "#C6E86B",
}: LayeredDescentHeroProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
const advanceRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const stage = stageRef.current;
if (!root || !stage) return;
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
const beatEls = Array.from(root.querySelectorAll<HTMLElement>("[data-beat]"));
const advance = advanceRef.current;
const travel = () => root.offsetHeight - window.innerHeight;
const progress = () => {
const t = travel();
const p = t > 0 ? -root.getBoundingClientRect().top / t : 0;
return p < 0 ? 0 : p > 1 ? 1 : p;
};
// Clamped 0→1 across a sub-range of the overall progress. Done in JS rather
// than nested CSS clamp() so the stylesheet stays plain multiplication,
// which is the part that has to stay debuggable.
const seg = (p: number, a: number, b: number) => {
const v = (p - a) / (b - a);
return v < 0 ? 0 : v > 1 ? 1 : v;
};
const tick = () => {
const p = progress();
const s = stage.style;
s.setProperty("--p", String(p));
// THE ORDER IS A SEQUENCE, not a set of numbers: the opening copy clears
// out first, THEN the riser starts up, with the scene moving as it comes.
// --c and --s stay CONTIGUOUS and RATE-MATCHED, so there is neither a
// stall at the handover nor a visible change of gear. Alter either
// distance and its range has to be rescaled to match.
s.setProperty("--d", String(seg(p, 0.02, 0.2)));
s.setProperty("--c", String(seg(p, 0.04, 0.184)));
s.setProperty("--s", String(seg(p, 0.184, 0.344)));
for (const el of beatEls) {
const from = parseFloat(el.dataset.from ?? "0");
const to = parseFloat(el.dataset.to ?? "0");
el.classList.toggle("on", p >= from && p <= to);
}
// Appears once the opening copy is out of the way and stays for the rest
// of the descent — the long, least obvious stretch is exactly where a
// reader needs telling to keep going.
advance?.classList.toggle("on", p > 0.045 && p < 0.93);
};
const onAdvance = () => {
const t = travel();
const p = progress();
let next: number | null = null;
for (const el of beatEls) {
const from = parseFloat(el.dataset.from ?? "0");
const to = parseFloat(el.dataset.to ?? "0");
const mid = (from + Math.min(to, 1)) / 2;
if (mid > p + 0.012) {
next = mid;
break;
}
}
if (next === null) next = 1;
window.scrollTo({ top: root.offsetTop + next * t, behavior: "smooth" });
};
advance?.addEventListener("click", onAdvance);
let queued = false;
const onScroll = () => {
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
tick();
});
};
// is-motion goes on LAST, after every listener is attached and one tick has
// run. If anything above throws, the class is never applied and the section
// stays the readable stack it renders as by default. Never move this up.
tick();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
root.classList.add("is-motion");
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
advance?.removeEventListener("click", onAdvance);
root.classList.remove("is-motion");
};
}, []);
const planeRules = planes
.map(
(pl, i) => `
.ld.is-motion .ld__plane[data-i="${i}"] { transform: translate3d(0, calc(var(--d) * -${pl.travelVh}vh), 0); }`
)
.join("");
const css = `
.ld { position: relative; background: ${background}; color: ${ink}; }
.ld, .ld * { box-sizing: border-box; }
/* STATIC STATE — what renders with no JS and under reduced motion. A stack
that reads top to bottom, nothing hidden. */
.ld__stage { position: relative; }
.ld__scene { position: relative; }
.ld__plane { display: block; }
.ld__plane img { display: block; width: 100%; height: auto; }
.ld__beat { position: relative; padding: 56px 24px; }
.ld__advance { display: none; }
.ld__riser img { display: block; width: 100%; height: auto; }
.ld__in { max-width: 1200px; margin: 0 auto; text-align: center; }
.ld__eyebrow { font-size: .74rem; font-weight: 700; letter-spacing: .18em;
text-transform: uppercase; opacity: .75; margin: 0 0 14px; }
.ld__h { font-size: clamp(2rem, 5.1vw, 4rem); font-weight: 700; line-height: 1.02;
letter-spacing: -.02em; margin: 0; white-space: pre-line; }
.ld__body { margin: 20px auto 0; max-width: 52ch; font-size: 1.2rem; line-height: 1.5;
opacity: .92; }
.ld__ctas { margin-top: 28px; display: flex; gap: 12px; justify-content: center;
flex-wrap: wrap; }
.ld__pill { display: inline-flex; align-items: center; gap: 8px; border-radius: 999px;
padding: 13px 22px; font-size: .95rem; font-weight: 600; text-decoration: none;
border: 1px solid rgba(255,255,255,.28); color: ${ink}; }
.ld__pill--solid { background: ${accent}; border-color: ${accent}; color: #10240A; }
/* ── MOTION ─────────────────────────────────────────────────────────────
The container is tall; the stage inside it is pinned. */
.ld.is-motion { height: ${descentVh}vh; }
.ld.is-motion .ld__stage { position: sticky; top: 0; height: 100vh; min-height: 560px;
overflow: hidden; }
.ld.is-motion .ld__scene { position: absolute; inset: 0; height: 100%; }
.ld.is-motion .ld__plane { position: absolute; left: 0; right: 0; top: 0;
will-change: transform; }
.ld.is-motion .ld__plane[data-bottom] { top: auto; bottom: 0; }
${planeRules}
/* Clouds: three copies of one plate, translated by exactly one copy width, so
the first and last frame are identical and the loop has no seam. The art
has empty left/right edges, so the join falls through open sky. The
vertical parallax is on the LAYER, the drift is on the TRACK inside it —
two elements, one transform each, so neither is in the other's way. */
.ld__track { display: flex; width: 300%; }
.ld__track img { width: 33.3333%; height: auto; flex: none; }
.ld.is-motion .ld__track { animation: ld-drift 60s linear infinite; }
@keyframes ld-drift { from { transform: translate3d(0,0,0); }
to { transform: translate3d(-33.3333%,0,0); } }
/* ONE element, TWO terms, and the gap between them is a deliberate pause:
the scene keeps moving, the plate stops, then the plate keeps moving.
--c lifts it by its own height, so it lands filling the bottom of the
frame with the scene still showing above it. --s then carries it off the
top and brings its ground up behind it. Because it is one box, the ground
can never overtake the plate — the geometry enforces what timing alone
only approximated. */
.ld.is-motion .ld__riser { position: absolute; left: 0; right: 0; top: 100%;
height: auto; pointer-events: none; z-index: 4;
transform: translate3d(0, calc(var(--c) * -1 * min(56.25vw, 94vh) + var(--s) * -100vh), 0);
will-change: transform; }
.ld.is-motion .ld__scrim { display: block; position: absolute; inset: 0; z-index: 5;
opacity: var(--s, 0); pointer-events: none;
background: radial-gradient(120% 78% at 50% 46%, rgba(18,10,4,.34) 0%, rgba(18,10,4,.72) 100%); }
.ld.is-motion .ld__beat { position: absolute; left: 0; right: 0; padding: 0 24px;
opacity: 0; transform: translateY(14px); z-index: 20;
transition: opacity .45s ease, transform .45s ease;
/* .on is REQUIRED here and is not tidiness. Without it a faded-out beat
still catches every click meant for what is under it — which is exactly
what ate the opening buttons' hover on the page this came from. */
pointer-events: none; }
.ld.is-motion .ld__beat.on { opacity: 1; transform: none; pointer-events: auto; }
.ld.is-motion .ld__beat[data-place="center"] { top: 50%; transform: translateY(calc(-50% + 14px)); }
.ld.is-motion .ld__beat[data-place="center"].on { transform: translateY(-50%); }
.ld.is-motion .ld__beat[data-place="bottom"] { bottom: 8vh; }
.ld.is-motion .ld__advance { display: block; position: absolute; left: 50%; bottom: 24px;
z-index: 40; transform: translateX(-50%); opacity: 0; pointer-events: none;
transition: opacity .4s ease; background: rgba(255,255,255,.1); color: ${ink};
border: 1px solid rgba(255,255,255,.3); border-radius: 999px; width: 44px; height: 44px;
cursor: pointer; font-size: 1.1rem; line-height: 1; }
.ld.is-motion .ld__advance.on { opacity: 1; pointer-events: auto; }
.ld.is-motion .ld__advance:hover { background: rgba(255,255,255,.2); }
@media (max-width: 760px) {
.ld.is-motion { height: ${descentVhMobile}vh; }
.ld__h { font-size: clamp(1.7rem, 7vw, 2.4rem); }
.ld__body { font-size: 1.02rem; }
}
/* Belt and braces: the effect already bails, but a reader who flips the OS
setting after load gets the still as well. */
@media (prefers-reduced-motion: reduce) {
.ld.is-motion { height: auto; }
.ld.is-motion .ld__stage { position: relative; height: auto; overflow: visible; }
.ld.is-motion .ld__scene { position: relative; inset: auto; }
.ld.is-motion .ld__plane { position: relative; transform: none !important; }
.ld.is-motion .ld__beat { position: relative; opacity: 1; transform: none;
pointer-events: auto; padding: 56px 24px; }
.ld.is-motion .ld__riser { position: relative; top: auto; transform: none; }
.ld.is-motion .ld__advance { display: none; }
.ld.is-motion .ld__track { animation: none; }
}
`;
return (
<div className="ld" ref={rootRef}>
<style>{css}</style>
<div className="ld__stage" ref={stageRef}>
{/* Decorative in full — every word lives in a beat, so a screen reader
gets the copy and none of the scenery. */}
<div className="ld__scene" aria-hidden="true">
{planes.map((pl, i) =>
pl.driftPx ? (
<div
key={pl.src + i}
className="ld__plane"
data-i={i}
data-bottom={pl.bottom ? "" : undefined}
>
<div className="ld__track">
<img src={pl.src} alt="" />
<img src={pl.src} alt="" />
<img src={pl.src} alt="" />
</div>
</div>
) : (
<div
key={pl.src + i}
className="ld__plane"
data-i={i}
data-bottom={pl.bottom ? "" : undefined}
>
<img src={pl.src} alt="" />
</div>
)
)}
</div>
{riser && (
<div className="ld__riser" aria-hidden="true">
<img src={riser.src} alt="" />
{riser.groundSrc && <img src={riser.groundSrc} alt="" />}
</div>
)}
<div className="ld__scrim" aria-hidden="true" />
{beats.map((b, i) => (
<div
key={i}
className="ld__beat"
data-beat
data-from={b.from}
data-to={b.to}
data-place={b.place ?? "center"}
>
<div className="ld__in">
{b.eyebrow && <p className="ld__eyebrow">{b.eyebrow}</p>}
{b.headline && <h1 className="ld__h">{b.headline}</h1>}
{b.body && <p className="ld__body">{b.body}</p>}
{b.ctas && b.ctas.length > 0 && (
<div className="ld__ctas">
{b.ctas.map((c) => (
<a
key={c.href + c.label}
className={c.solid ? "ld__pill ld__pill--solid" : "ld__pill"}
href={c.href}
>
{c.label}
</a>
))}
</div>
)}
</div>
</div>
))}
{showAdvance && (
<button
className="ld__advance"
type="button"
ref={advanceRef}
aria-label="Continue to the next section"
>
↓
</button>
)}
</div>
</div>
);
} Claude Code Instructions
CLI Install
npx innovations add layered-descentWhere to use it
Use this when a business has a STORY WITH DEPTH — something you arrive at by going down or in. Soil under a field, a seam under rock, layers of a process, a product's inside. It is the most expensive hero in this registry in both bytes and attention, so it has to be earning that: on a business whose argument is "look closer", it is the argument; on a business whose argument is "call us today", it is a wall between the reader and the phone number.
In Astro:
---
import LayeredDescentHero from '../components/innovations/heroes/layered-descent';
---
<LayeredDescentHero client:load />
THE STATIC STATE IS THE DEFAULT, AND THAT IS THE WHOLE SAFETY STORY. Every rule that moves anything lives under .ld.is-motion, and that class is added by the effect only after every listener is attached and one tick has run. No JS, a thrown error, or prefers-reduced-motion means the class never lands and the section renders as a plain readable stack — scene, then copy, nothing hidden. Nothing starts at opacity 0. If you edit the effect, never move the classList.add above the work.
Props: descentVh / descentVhMobile (how much scroll the pin eats), planes, riser, beats, showAdvance, background, ink, accent.
planes: each is { src, travelVh } and travelVh IS the parallax — bigger means nearer the camera. Different multipliers off one progress number is the entire effect; there is no per-layer JS. Add driftPx to render a plane as a seamless three-copy track (clouds); the art for that MUST have empty left and right edges or the loop seams visibly. Add bottom to pin a plane to the bottom of the frame instead of the top.
THE ART HAS TO BE SEPARATE PLANES WITH REAL ALPHA. A flat image cannot be parallaxed. Generating them has a trap worth knowing: asked for a transparent background, image models render a PICTURE of a transparency checkerboard — opaque grey squares. Generate each plane on a flat magenta ground and key that out afterwards.
riser: the optional two-term travelling unit — a foreground plate that rises to fill the frame, then carries its ground up behind it. It is welded into ONE box on purpose. Because it is one box, the ground can never overtake the plate; the geometry enforces an order that timing alone only approximates. Do not split it back into two layers to "simplify" it.
beats: { from, to } are scroll-progress fractions of the WHOLE container, so if you change descentVh, scale every range rather than nudging them — otherwise the opening slows to a crawl. The .on class carries pointer-events as well as opacity, and that is required: a faded-out beat without it still catches every click meant for what is under it.
The advance arrow SCROLLS, it does not drive its own state. Everything stays a function of scroll position, so a click and a trackpad flick land in the same place and there is no second source of truth to get out of sync.
The demo copy is placeholder. Replace it with the business's own — and note the reference page this came from carries no stat counters and no ratings on purpose. A hero this immersive makes an invented number look more credible, not less, which is a reason for more care rather than less.