Innovations
heroes /

Depth Stack (Chaptered Parallax Scenes)

Full-height scenes stacked one after another, four depth layers each, moving on scroll AND on the pointer. The oversized word sits BEHIND the subject so the figure stands in front of its own letters. One chrome layer stays fixed over every scene. Rates measured off Slider Revolution

Preview

Source

tsx
"use client";

import { useEffect, useRef } from "react";

/**
 * Depth Stack — full-height scenes, four depth layers each, moving on scroll
 * AND on the pointer.
 *
 * Measured off sliderrevolution.com's "4 Seasons" parallax theme. It is four
 * stacked sections rather than a carousel — you can watch one season's section
 * end and the next begin — with a single chrome layer fixed over all of them.
 *
 * THE FOUR LAYERS AND THEIR RATES, sampled off the live DOM across 500px of
 * scroll. Each moves at a fraction of the page, and the fraction IS the depth:
 *
 *     background   0.56x scroll   lag 0.44   furthest
 *     the word     0.66x          lag 0.34
 *     the subject  0.71x          lag 0.29
 *     foreground   0.91x          lag 0.09   nearest
 *
 * The composition trick worth copying is the middle two: the WORD sits behind
 * the SUBJECT, so the figure stands in front of its own letters and the letters
 * pass behind a shoulder. That overlap is what sells the depth — four layers
 * that never cross each other read as four flat planes, not as a place.
 *
 * WHY THE LAG NEVER EXPOSES AN EDGE, which looks like luck and is not: a
 * lagging layer is pushed DOWN relative to its scene as the scene rises, so the
 * gap it opens is always at the scene's TOP — which by then is above the
 * viewport. Entering from below, the same lag opens a gap at the bottom, which
 * is still under the fold. The exposure is always on the edge nobody can see.
 * The modest 8-14% oversize is there for the POINTER offset, which has no such
 * guarantee, and for blur bleed.
 *
 * ── THE STATIC STATE IS THE DEFAULT.
 * Both parallaxes live under `.ds.is-motion`, added by the effect and nowhere
 * else. With no JS, a thrown error, or prefers-reduced-motion every scene
 * renders as a still, composed poster — which for this hero is barely a
 * downgrade, because the layers are a composition before they are an effect.
 */

export interface DepthLayer {
  /** Image. Null renders the scene's placeholder wash for that depth. */
  src?: string | null;
  alt?: string;
  /** Fraction of scroll this layer does NOT do. 0 moves with the page, 1 holds
   *  still. The reference runs 0.44 / 0.34 / 0.29 / 0.09 back to front. */
  lag?: number;
  /** Pointer travel in px at full deflection. Near layers move more. */
  tilt?: number;
  /** How far past the frame the image extends, as a percentage. Covers the
   *  pointer offset, which the scroll lag's self-protection does not. */
  bleed?: number;
}

export interface DepthScene {
  /** The oversized display word. It sits BEHIND the subject on purpose. */
  word: string;
  /** Script line under the word. */
  lead?: string;
  body?: string;
  cta?: { label: string; href: string } | null;
  background?: DepthLayer;
  subject?: DepthLayer;
  foreground?: DepthLayer;
  /** Two colours for the placeholder wash, used wherever a layer has no src. */
  tint?: [string, string];
  /** Overrides the component accent for this scene. */
  accent?: string;
  /** Exposure lift on this scene's photographic planes — brightness, and a
   *  touch of saturation with it so a lifted image does not go chalky. 1 is the
   *  photograph as supplied. Push it rather than reaching for a scrim: it makes
   *  the picture the thing you are adjusting, not the thing you are hiding. */
  lift?: number;
  /** Opacity of the display word, 0 to 1. Dropping it below 1 sets the word
   *  INTO the photograph instead of on top of it — the letters take the room's
   *  colour and the picture stays legible through them. On a bright scene this
   *  is the alternative to darkening the photograph behind white type. */
  wordOpacity?: number;
  /** Which way the copy is set on this scene. A bright photograph cannot carry
   *  white body text, and the two ways out are a panel behind the words or ink
   *  words — this is the second, because a panel is a hole punched in the
   *  picture. 'light' is white copy for dark scenes, 'dark' is ink copy for
   *  bright ones. It flips the halo direction too: type on a light ground needs
   *  a white glow, not a black one. */
  copyTone?: "light" | "dark";
  /** 0 to 1. Darkens THIS scene's background plane only. A white kitchen and a
   *  night garden cannot both carry white display type at the same exposure,
   *  and the honest lever is the photograph rather than heavier shadows on the
   *  text. Applied to the background layer alone, so the subject and the
   *  foreground keep their own light and still read as nearer. */
  dim?: number;
}

export interface DepthStackHeroProps {
  /** Logo image. Supplied, it replaces the number-and-word mark entirely. */
  logo?: string | null;
  logoWidth?: number;
  logoAlt?: string;
  markNumber?: string;
  markWord?: string;
  menuLabel?: string;
  socials?: { label: string; href: string; icon: "facebook" | "instagram" | "youtube" }[];
  cornerNote?: [string, string] | null;

  scenes?: DepthScene[];

  /** Master multiplier on every layer's pointer travel. 0 turns the pointer
   *  parallax off and leaves the scroll parallax alone. */
  tilt?: number;
  accent?: string;
  ink?: string;
  /** Scene height. Below about 88 the word and the copy start to crowd. */
  sceneVh?: number;
}

const SEASONS: DepthScene[] = [
  {
    // A worked example of splitting ONE photograph into planes. The room was
    // inpainted with the island and pendants removed, the island was keyed off
    // a magenta field, and the pendants off a WHITE one — a 1-2px brass lattice
    // over chroma is nothing but blended pixels and no key recovers it, while
    // against white the metal is a saturated mid-tone and separates cleanly.
    // The white halo that leaves costs nothing: the layer sits over a ceiling.
    word: "Kitchen",
    lead: "Where the day actually happens",
    body: "Morning light, the good knife, and everyone somehow standing in the same square metre. This is the room the rest of the house is arranged around.",
    cta: { label: "See the build", href: "#" },
    tint: ["#c9c2b6", "#4a423a"],
    accent: "#e8b93a",
    lift: 1.1,
    wordOpacity: 0.5,
    copyTone: "dark",
    background: { src: "/heroes/depth-stack/kitchen-room.webp", alt: "A bright white kitchen with hardwood floors" },
    subject: { src: "/heroes/depth-stack/kitchen-island.webp", alt: "The marble-topped kitchen island and bar stools" },
    foreground: { src: "/heroes/depth-stack/kitchen-pendants.webp", alt: "Two brass lantern pendant lights", lag: 0.02, bleed: 6 },
  },
  {
    // Second worked example, and the composition was ARRANGED for the split
    // before the shot existed: a freestanding tub alone in the middle of the
    // floor to be the mid plane, and something hanging directly above it for
    // the near one. Deciding the planes first and then generating to suit is
    // far less work than trying to cut a photograph that was never composed
    // to come apart.
    word: "Bath",
    lead: "The long soak, earned",
    body: "Hex marble underfoot, a tub deep enough to lie down in, a shower with room to turn around, and granite running wall to wall.",
    cta: { label: "See the stone", href: "#" },
    tint: ["#cfd3d6", "#4a5157"],
    accent: "#c8952f",
    lift: 1.08,
    wordOpacity: 0.5,
    copyTone: "dark",
    background: { src: "/heroes/depth-stack/bath-room.webp", alt: "A bright marble bathroom with a walk-in shower and granite vanity" },
    subject: { src: "/heroes/depth-stack/bath-tub.webp", alt: "A white freestanding soaking tub" },
    foreground: { src: "/heroes/depth-stack/bath-pendants.webp", alt: "Two brass lantern pendant lights", lag: 0.02, bleed: 6 },
  },
  {
    // Third worked example, and the first where the near plane is FOLIAGE. It
    // is the easiest cut in the set: green leaves cannot have both red and blue
    // above green, so on a magenta field every contaminated pixel is
    // identifiable by definition and can be de-spilled at full strength — not
    // just at the fringe, which is all you dare do on brass or wood where
    // warm-but-cool-ish pixels are real. If you get to choose the near plane,
    // choose something green.
    word: "Outdoor",
    lead: "The room without a roof",
    body: "Granite that shrugs off the weather, a bar you can cook the whole evening from, and the water three steps away.",
    cta: { label: "Plan the patio", href: "#" },
    tint: ["#b79a6d", "#3d3527"],
    accent: "#e0a437",
    lift: 1.05,
    wordOpacity: 0.5,
    copyTone: "dark",
    background: { src: "/heroes/depth-stack/out-room.webp", alt: "A stone patio beside a swimming pool with a modern house beyond" },
    subject: { src: "/heroes/depth-stack/out-island.webp", alt: "An outdoor kitchen island with a granite countertop and bar stools" },
    foreground: { src: "/heroes/depth-stack/out-fore.webp", alt: "A leafy branch overhanging the patio", lag: 0.02, bleed: 6 },
  },
  {
    // A commercial scene, and the first where the mid plane had to be asked for
    // TWICE. The first cut came back with the marble feature wall and the palm
    // still attached to the desk — both belong to other planes, and doubling
    // them would have torn the moment the layers moved apart. Naming what must
    // be DELETED, explicitly and by name, is what fixed it; describing only
    // what to keep leaves too much room.
    word: "Reception",
    lead: "First impression, in stone",
    body: "A book-matched feature wall, a granite counter with a waterfall edge, and stacked stone that tells people where they are before anyone says a word.",
    cta: { label: "See commercial work", href: "#" },
    tint: ["#cbbba4", "#4a4034"],
    accent: "#c08a2b",
    lift: 1.06,
    wordOpacity: 0.5,
    copyTone: "dark",
    background: { src: "/heroes/depth-stack/rec-room.webp", alt: "A hotel lobby with a book-matched marble feature wall" },
    subject: { src: "/heroes/depth-stack/rec-desk.webp", alt: "A reception desk with a granite top and stacked stone front" },
    foreground: { src: "/heroes/depth-stack/rec-palm.webp", alt: "Palm fronds framing the left of the lobby", lag: 0.02, bleed: 6 },
  },
  {
    // The only DARK scene in the set, and it is the one that proves the copy
    // controls earn their keep: no lift fighting a bright room, white copy
    // instead of ink, and the word at the same 0.5 reading as a glow rather
    // than a shadow. Same component, opposite exposure, nothing special-cased.
    word: "Bar",
    lead: "Where the evening settles",
    body: "One slab running the length of the room, fluted walnut beneath it, brass at the foot, and back-bar light that makes every bottle look expensive.",
    cta: { label: "See the slab", href: "#" },
    tint: ["#7a5a33", "#1c1710"],
    accent: "#dda94a",
    lift: 1.06,
    wordOpacity: 0.5,
    copyTone: "light",
    background: { src: "/heroes/depth-stack/bar-room.webp", alt: "A backlit bar wall of spirit bottles and marble" },
    subject: { src: "/heroes/depth-stack/bar-counter.webp", alt: "A bar counter with a granite top, fluted walnut front and leather stools" },
    foreground: { src: "/heroes/depth-stack/bar-pendants.webp", alt: "Three bronze dome pendant lights", lag: 0.02, bleed: 6 },
  },
];

function Icon({ name }: { name: string }) {
  if (name === "instagram")
    return (
      <svg viewBox="0 0 24 24" aria-hidden="true">
        <rect x="3.6" y="3.6" width="16.8" height="16.8" rx="5" fill="none" stroke="currentColor" strokeWidth="1.7" />
        <circle cx="12" cy="12" r="3.9" fill="none" stroke="currentColor" strokeWidth="1.7" />
        <circle cx="17.3" cy="6.7" r="1.15" fill="currentColor" />
      </svg>
    );
  if (name === "youtube")
    return (
      <svg viewBox="0 0 24 24" aria-hidden="true">
        <rect x="2.2" y="5.4" width="19.6" height="13.2" rx="4" fill="none" stroke="currentColor" strokeWidth="1.7" />
        <path d="M10.4 9.1 15.4 12l-5 2.9V9.1Z" fill="currentColor" />
      </svg>
    );
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true">
      <path
        d="M13.2 21v-8h2.7l.4-3.1h-3.1V7.9c0-.9.25-1.5 1.55-1.5H16.4V3.6c-.29-.04-1.27-.12-2.41-.12-2.39 0-4.02 1.46-4.02 4.13V9.9H7.25V13h2.72v8h3.23Z"
        fill="currentColor"
      />
    </svg>
  );
}

/* THE POINTER MOVES THE WORD AND NOTHING ELSE. Every plane following the mouse
   makes the whole photograph swim, and a photograph of a real room reads as a
   window — windows do not slosh. Leaving it on the word alone keeps the effect
   as a property of the TYPE, which is the one element that is not pretending to
   be a thing in the room, and the scroll parallax still carries all four planes.
   Raise a layer's tilt if you want it back, and raise its bleed with it. */
const DEFAULTS = {
  background: { lag: 0.44, tilt: 0, bleed: 10 },
  word: { lag: 0.34, tilt: 11 },
  subject: { lag: 0.29, tilt: 0, bleed: 14 },
  foreground: { lag: 0.09, tilt: 0, bleed: 8 },
};

function layerVars(l: DepthLayer | undefined, d: { lag: number; tilt: number }) {
  return {
    "--lag": String(l?.lag ?? d.lag),
    "--tilt": String(l?.tilt ?? d.tilt),
  } as React.CSSProperties;
}

function bleedStyle(l: DepthLayer | undefined, d: number) {
  const b = l?.bleed ?? d;
  return { inset: `${-b / 2}%`, width: `${100 + b}%`, height: `${100 + b}%` } as React.CSSProperties;
}

export default function DepthStackHero({
  logo = "/heroes/depth-stack/rms-logo.svg",
  logoWidth = 108,
  logoAlt = "RMS",
  markNumber = "4",
  markWord = "Seasons",
  menuLabel = "Menu",
  socials = [
    { label: "Facebook", href: "#", icon: "facebook" },
    { label: "Instagram", href: "#", icon: "instagram" },
    { label: "YouTube", href: "#", icon: "youtube" },
  ],
  cornerNote = ["Made with", "Beautiful Possibilities"],
  scenes = SEASONS,
  tilt = 1,
  accent = "#ffd233",
  ink = "#101010",
  sceneVh = 100,
}: DepthStackHeroProps) {
  const rootRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;

    const sceneEls = Array.from(root.querySelectorAll<HTMLElement>(".ds__scene"));
    let queued = false;

    // --c is the scene's distance from CENTRED, in px. Zero when the scene sits
    // in the middle of the viewport, positive below, negative above. Every
    // layer's offset is that one number times its own lag, which is what keeps
    // four planes locked to a single truth instead of four scroll listeners
    // disagreeing by a frame.
    const place = () => {
      const vh = window.innerHeight;
      for (const el of sceneEls) {
        const r = el.getBoundingClientRect();
        if (r.bottom < -vh || r.top > vh * 2) continue; // far off-screen: skip
        el.style.setProperty("--c", String(Math.round(r.top + r.height / 2 - vh / 2)));
      }
    };

    const onScroll = () => {
      if (queued) return;
      queued = true;
      requestAnimationFrame(() => {
        queued = false;
        place();
      });
    };

    // Pointer is written on the ROOT, once, and inherited. Writing it per layer
    // would be sixteen style mutations a frame for one number.
    let pQueued = false;
    let px = 0;
    let py = 0;
    const onPointer = (e: PointerEvent) => {
      px = (e.clientX / window.innerWidth) * 2 - 1;
      py = (e.clientY / window.innerHeight) * 2 - 1;
      if (pQueued) return;
      pQueued = true;
      requestAnimationFrame(() => {
        pQueued = false;
        root.style.setProperty("--mx", px.toFixed(3));
        root.style.setProperty("--my", py.toFixed(3));
      });
    };
    const onLeave = () => {
      root.style.setProperty("--mx", "0");
      root.style.setProperty("--my", "0");
    };

    place();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    window.addEventListener("pointermove", onPointer, { passive: true });
    document.addEventListener("pointerleave", onLeave);
    root.classList.add("is-motion");

    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
      window.removeEventListener("pointermove", onPointer);
      document.removeEventListener("pointerleave", onLeave);
      root.classList.remove("is-motion");
    };
  }, []);

  const vars = {
    "--accent": accent,
    "--ink": ink,
    "--scene-vh": `${sceneVh}svh`,
    "--tilt-mul": String(tilt),
    "--logo-w": `${logoWidth}px`,
    "--mx": "0",
    "--my": "0",
  } as React.CSSProperties;

  return (
    <section className="ds" ref={rootRef} style={vars}>
      <style>{CSS}</style>

      {/* One chrome layer over every scene. Sticky at full height with its own
          height pulled back out of the flow, so it can anchor to the TOP and
          the BOTTOM of the viewport for as long as the stack is on screen —
          which position:fixed would do too, and would then keep doing for the
          rest of the page. */}
      <div className="ds__chrome" aria-hidden="false">
        <a className={logo ? "ds__mark ds__mark--logo" : "ds__mark"} href="#">
          {logo ? (
            <img src={logo} alt={logoAlt} style={{ width: `${logoWidth}px` }} />
          ) : (
            <>
              <b>{markNumber}</b>
              <span>{markWord}</span>
            </>
          )}
        </a>

        <button className="ds__menu" aria-label={menuLabel}>
          <i /><i /><i />
        </button>

        {socials.length > 0 && (
          <nav className="ds__social">
            {socials.map((s) => (
              <a key={s.label} href={s.href} aria-label={s.label}>
                <Icon name={s.icon} />
              </a>
            ))}
          </nav>
        )}

        {cornerNote && (
          <span className="ds__corner">
            <em>{cornerNote[0]}</em>
            <b>{cornerNote[1]}</b>
          </span>
        )}
      </div>

      <div className="ds__scenes">
        {scenes.map((sc, i) => {
          const tint = sc.tint ?? SEASONS[i % 4].tint!;
          const sceneStyle = {
            "--tint-a": tint[0],
            "--tint-b": tint[1],
            "--dim": String(sc.dim ?? 0),
            "--lift": String(sc.lift ?? 1),
            "--word-o": String(sc.wordOpacity ?? 1),
            ...(sc.accent ? { "--accent": sc.accent } : {}),
          } as React.CSSProperties;

          return (
            <section
              className={sc.copyTone === "dark" ? "ds__scene ds__scene--inkcopy" : "ds__scene"}
              key={i}
              style={sceneStyle}
            >
              <div className="ds__layer ds__layer--bg" style={layerVars(sc.background, DEFAULTS.background)}>
                {sc.background?.src ? (
                  <img src={sc.background.src} alt={sc.background.alt ?? ""} style={bleedStyle(sc.background, DEFAULTS.background.bleed)} />
                ) : (
                  <div className="ds__wash" style={bleedStyle(sc.background, DEFAULTS.background.bleed)} />
                )}
              </div>

              {/* BEHIND the subject. The figure standing in front of its own
                  letters is the shot; put the word on top and the depth dies. */}
              <div className="ds__layer ds__layer--word" style={layerVars(undefined, DEFAULTS.word)}>
                <h2 className="ds__word">{sc.word}</h2>
              </div>

              <div className="ds__layer ds__layer--subject" style={layerVars(sc.subject, DEFAULTS.subject)}>
                {sc.subject?.src ? (
                  <img src={sc.subject.src} alt={sc.subject.alt ?? ""} style={bleedStyle(sc.subject, DEFAULTS.subject.bleed)} />
                ) : (
                  <div className="ds__ghost" aria-hidden="true" />
                )}
              </div>

              <div className="ds__layer ds__layer--fore" style={layerVars(sc.foreground, DEFAULTS.foreground)}>
                {sc.foreground?.src ? (
                  <img src={sc.foreground.src} alt={sc.foreground.alt ?? ""} style={bleedStyle(sc.foreground, DEFAULTS.foreground.bleed)} />
                ) : (
                  <div className="ds__haze" aria-hidden="true" />
                )}
              </div>

              <div className="ds__copy">
                {sc.lead && <p className="ds__lead">{sc.lead}</p>}
                {sc.body && <p className="ds__body">{sc.body}</p>}
                {sc.cta && (
                  <a className="ds__cta" href={sc.cta.href}>
                    {sc.cta.label}
                  </a>
                )}
              </div>
            </section>
          );
        })}
      </div>
    </section>
  );
}

const CSS = `
.ds {
  --display: 'Poppins', 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
  --script: 'Caveat', 'Segoe Script', 'Brush Script MT', cursive;
  position: relative;
  background: #0b0d09;
  color: #fff;
  font-family: var(--display);
  -webkit-font-smoothing: antialiased;
}
.ds *, .ds *::before, .ds *::after { box-sizing: border-box; }
.ds a { color: inherit; text-decoration: none; }

/* ── Chrome. Sticky at viewport height with that height pulled back out of the
      flow, so it anchors top AND bottom for exactly as long as the stack is on
      screen — and stops when the stack does, which position:fixed would not. */
.ds__chrome {
  position: sticky; top: 0; z-index: 10;
  height: 100vh; height: 100svh; margin-bottom: calc(-1 * 100svh);
  pointer-events: none;
}
.ds__chrome > * { pointer-events: auto; }
.ds__mark { position: absolute; left: 50px; top: 40px; display: flex; align-items: flex-end; gap: 8px; }
/* 30px from the left and anchored to the top, per spec. The logo carries its
   own dark strokes, so it gets a soft drop-shadow rather than a plate — the
   same rule the rest of this scene follows: no panels over the photograph. */
.ds__mark--logo { left: 30px; top: 24px; display: block; }
.ds__mark--logo img { display: block; height: auto; filter: drop-shadow(0 2px 10px rgba(0, 0, 0, .45)); }
.ds__mark b { font-size: 42px; font-weight: 300; line-height: .8; }
.ds__mark span { font-size: 15px; letter-spacing: .06em; text-transform: uppercase; padding-bottom: 3px; }
.ds__menu {
  position: absolute; right: 46px; top: 44px;
  display: grid; gap: 7px; justify-items: end;
  background: none; border: 0; padding: 6px; cursor: pointer;
}
/* Staggered bar widths, straight off the reference — 40/30/40. */
.ds__menu i { display: block; height: 3px; background: #fff; border-radius: 2px; }
.ds__menu i:nth-child(1) { width: 40px; }
.ds__menu i:nth-child(2) { width: 30px; }
.ds__menu i:nth-child(3) { width: 40px; }
.ds__social { position: absolute; left: 52px; bottom: 44px; display: flex; gap: 22px; }
.ds__social svg { width: 21px; height: 21px; display: block; opacity: .82; }
.ds__social a:hover svg { opacity: 1; }
.ds__corner { position: absolute; right: 50px; bottom: 40px; display: flex; flex-direction: column; align-items: flex-end; }
.ds__corner em { font-style: normal; font-size: 11px; letter-spacing: .14em; text-transform: uppercase; opacity: .7; }
.ds__corner b { font-size: 19px; font-weight: 500; }

/* ── A scene ─────────────────────────────────────────────────────────────── */
.ds__scene {
  --c: 0;
  position: relative; isolation: isolate;
  height: var(--scene-vh); min-height: 560px;
  overflow: hidden;
  display: grid; place-items: center;
}
.ds__layer { position: absolute; inset: 0; }
.ds__layer--bg { z-index: 1; }
/* The dim rides on the background plane only — a scrim over the whole scene
   would flatten the subject and the foreground into it and undo the depth the
   layers exist for. */
.ds__layer--bg::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: rgba(10, 10, 12, var(--dim, 0));
}
/* The word sits ABOVE centre and the copy below it — in the reference the word
   centres around 42% of the scene and the copy owns the bottom third. Centring
   both and nudging the copy down with a margin puts the body text straight
   through the letters, which is what the first build did. */
.ds__layer--word { z-index: 2; display: grid; place-items: center; align-content: center; padding-bottom: 24vh; }
.ds__layer--subject { z-index: 3; }
.ds__layer--fore { z-index: 4; }
.ds__copy { position: relative; z-index: 5; }

.ds__layer img, .ds__wash, .ds__ghost, .ds__haze {
  position: absolute; display: block; object-fit: cover; max-width: none;
  /* Saturation rides with brightness. Lifting exposure alone washes colour out
     of a photograph and it comes back looking chalky rather than brighter. */
  filter: brightness(var(--lift, 1)) saturate(calc(1 + (var(--lift, 1) - 1) * 1.6));
}

/* ONE number drives all four planes: --c, the scene's distance from centred.
   Times each layer's own lag. Four listeners would disagree by a frame; one
   number cannot. The pointer term rides on top of the same transform so the two
   parallaxes compose instead of fighting over the property. */
.ds.is-motion .ds__layer {
  will-change: transform;
  transform: translate3d(
    calc(var(--mx) * var(--tilt) * var(--tilt-mul) * -1px),
    calc((var(--c) * var(--lag) + var(--my) * var(--tilt) * var(--tilt-mul)) * -1px),
    0
  );
}

/* ── The word. Behind the subject, oversized, and clamped so it fills the
      frame at any width rather than being a font-size someone guessed. ───── */
.ds__word {
  margin: 0;
  opacity: var(--word-o, 1);
  font-size: clamp(88px, 21vw, 320px);
  font-weight: 700; line-height: .82; letter-spacing: -.02em;
  text-transform: uppercase; color: #fff;
  text-shadow: 0 24px 60px rgba(0, 0, 0, .34);
  white-space: nowrap;
}

/* ── Placeholder planes. The wash reads as a season, the ghost as a subject
      that has not arrived, the haze as an out-of-focus foreground. They are
      honest about being empty; this hero is four photographs deep and cannot
      make its case without them. ─────────────────────────────────────────── */
.ds__wash {
  background:
    radial-gradient(58% 42% at 50% 22%, color-mix(in srgb, var(--tint-a) 72%, #fff), transparent 70%),
    radial-gradient(70% 60% at 18% 80%, var(--tint-a), transparent 72%),
    linear-gradient(178deg, color-mix(in srgb, var(--tint-a) 60%, #000), var(--tint-b) 78%);
  filter: blur(10px);
}
.ds__ghost {
  left: 50%; bottom: 16%; width: min(19vw, 210px); height: min(52vh, 430px);
  transform: translateX(-50%);
  /* Narrow, standing, and pale rather than a dark disc — it has to read as a
     subject that has not arrived, not as a mark on the picture. It is still
     only a placeholder: this hero is three photographs deep per scene. */
  border-radius: 48% 48% 22% 22% / 26% 26% 12% 12%;
  background: linear-gradient(180deg, rgba(255,255,255,.20), rgba(255,255,255,.07) 55%, rgba(0,0,0,.16));
  box-shadow: inset 0 0 60px rgba(0,0,0,.18);
  filter: blur(3px);
}
.ds__haze {
  left: -6%; right: -6%; bottom: -12%; width: 112%; height: 46%;
  background:
    radial-gradient(50% 100% at 22% 100%, color-mix(in srgb, var(--tint-b) 90%, #000), transparent 72%),
    radial-gradient(46% 100% at 76% 100%, color-mix(in srgb, var(--tint-a) 70%, #000), transparent 74%),
    linear-gradient(to top, var(--tint-b), transparent);
  filter: blur(16px);
}

/* ── Copy ────────────────────────────────────────────────────────────────── */
/* No scrim behind the copy. The legibility budget is spent on the type itself —
   a tight dark halo plus a wide soft one — so the photograph is never veiled to
   make room for words. On a very bright scene push 'lift' down or set the copy
   in ink rather than reaching for a panel behind it. */
.ds__copy { align-self: end; text-align: center; max-width: 640px; padding: 0 24px 9vh; position: relative; }
.ds__lead {
  margin: 0; font-family: var(--script); color: var(--accent);
  font-size: clamp(28px, 3.4vw, 44px); line-height: 1.1;
  text-shadow: 0 1px 2px rgba(0, 0, 0, .55), 0 3px 20px rgba(0, 0, 0, .6);
}
/* Ink copy for a bright scene, and the halo flips with it — a black shadow
   under dark type on a light photograph just makes mud. The lead keeps the
   accent hue but drops its value, because the accent is chosen to sing against
   a dark room and disappears against a white one. */
.ds__scene--inkcopy .ds__lead {
  color: color-mix(in srgb, var(--accent) 58%, #4a3208);
  text-shadow: 0 1px 0 rgba(255, 255, 255, .55), 0 2px 16px rgba(255, 255, 255, .6);
}
.ds__scene--inkcopy .ds__body {
  color: #1d1b19;
  text-shadow: 0 1px 0 rgba(255, 255, 255, .6), 0 2px 14px rgba(255, 255, 255, .75);
}
.ds__body {
  margin: 14px auto 0; max-width: 52ch;
  font-size: 16px; line-height: 1.55; color: rgba(255, 255, 255, .88);
  text-shadow: 0 1px 2px rgba(0, 0, 0, .6), 0 2px 16px rgba(0, 0, 0, .7);
}
.ds__cta {
  display: inline-flex; align-items: center; margin-top: 26px;
  min-height: 52px; padding: 0 30px; border-radius: 10px;
  background: var(--accent); color: var(--ink);
  font-size: 13px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase;
  box-shadow: 0 14px 34px rgba(0, 0, 0, .35);
  transition: filter 180ms ease, transform 180ms ease;
}
.ds__cta:hover { filter: brightness(1.07); transform: translateY(-1px); }

.ds :focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

@media (max-width: 900px) {
  .ds__mark { left: 24px; top: 26px; }
  .ds__mark--logo { left: 20px; top: 18px; }
  .ds__mark--logo img { width: calc(var(--logo-w) * .78) !important; }
  .ds__menu { right: 22px; top: 30px; }
  .ds__social { left: 24px; bottom: 26px; gap: 16px; }
  .ds__corner { display: none; }
  .ds__copy { margin-top: 17vh; }
}
@media (max-width: 620px) {
  .ds__word { font-size: clamp(64px, 25vw, 150px); }
  .ds__social { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .ds *, .ds *::before, .ds *::after { animation: none !important; transition: none !important; }
}
`;
Claude Code Instructions

CLI Install

npx innovations add depth-stack

Where to use it

Use this when a site has three to five things to say and each one deserves the whole screen — seasons, chapters, collections, locations, stages of a process. It is a scrolled sequence, not a carousel: there are no arrows, no autoplay and no slide state, just sections that pass the viewport with their insides moving at different speeds. If the content is a list of features rather than a set of places, this is the wrong hero and a normal section stack is the right one. In Astro: --- import DepthStackHero from '../components/innovations/heroes/depth-stack'; --- <DepthStackHero client:load /> Needs client:load or client:visible. Server-rendered alone every scene is a still, composed poster — for this hero that is barely a downgrade, because the layers are a composition before they are an effect. THE FOUR RATES ARE THE COMPONENT, and they were sampled off the live reference across 500px of scroll rather than picked: background 0.56x scroll lag 0.44 furthest the word 0.66x lag 0.34 the subject 0.71x lag 0.29 foreground 0.91x lag 0.09 nearest Keep the ORDER above all else. Even spacing is not the point — the gap between the word and the subject is deliberately small (0.34 to 0.29) so those two feel like they occupy the same room, while the background falls a long way behind and the foreground rides almost with the page. Flatten them toward each other and you get a slideshow; spread them evenly and the middle two stop belonging together. THE WORD GOES BEHIND THE SUBJECT. That single overlap — a figure standing in front of its own letters, letters passing behind a shoulder — is what sells the depth. Four layers that never cross each other read as four flat planes, not as a place. If your subject is a cut-out with transparency, this works immediately; if it is a rectangular photograph it will not, and you should either cut it out or drop to three layers. WHY THE LAG NEVER EXPOSES AN EDGE, which looks like luck and is worth understanding before you change anything: a lagging layer is pushed DOWN relative to its scene as the scene rises, so the gap it opens is always at the scene's TOP, which by then is above the viewport. Entering from below, the same lag opens a gap at the bottom, still under the fold. The exposure is always on the edge nobody can see. The 8-14% 'bleed' is therefore NOT for the scroll — it is for the POINTER offset, which has no such guarantee, and for blur bleed. Raise a layer's tilt and raise its bleed with it. ONE NUMBER DRIVES ALL FOUR PLANES. Each scene publishes --c, its distance from centred in px, and every layer's offset is that times its own lag. Four scroll listeners would disagree by a frame and the planes would shear; one number cannot. The pointer term rides on the same transform so the two parallaxes compose rather than fighting over the property, and the pointer value is written ONCE on the root and inherited — writing it per layer is sixteen style mutations a frame for one number. The chrome is sticky at viewport height with that height pulled back out of the flow, so it anchors to the top AND the bottom for exactly as long as the stack is on screen. position:fixed would do the same and would then keep doing it for the rest of the page, which is a bug you find later on somebody else's section. SPLITTING ONE PHOTOGRAPH INTO PLANES, which is how the kitchen scene was made and is the cheapest way to feed this hero. Do NOT try to separate ceiling, walls and floor: they are joined surfaces in a single perspective, so moving one tears it off the others at the join and reads as broken geometry rather than depth. Split by DISTANCE FROM CAMERA instead, into things that are genuinely separate objects — for a room that is usually (1) the shell with everything fixed to it, (2) the furniture standing in the middle of it, (3) whatever hangs nearest the lens. Those can move apart believably because they are apart. BEST OF ALL, COMPOSE FOR THE SPLIT BEFORE THE PHOTOGRAPH EXISTS. If you are generating or art-directing the shot, put the mid-plane object alone in the middle of the floor with clear space around it and hang the near-plane object directly above it. Deciding the planes first and shooting to suit is far less work than trying to cut apart a photograph that was never composed to come apart — the bath scene was made that way and separated in three clean passes.\n\nNAME WHAT MUST BE DELETED, not just what to keep. Asking for 'only the reception desk' returned the desk with the marble feature wall and a palm still attached — both belong to other planes, and doubling them would have torn the moment the layers moved apart. Listing the excluded objects explicitly, by name, fixed it in one retry. The reliable shape of the prompt is: output flat <colour> everywhere EXCEPT this one object; these specific things must NOT appear; keep only these parts.\n\nThree steps: inpaint the mid and near objects OUT of the photograph for the background plate; key each object against a flat field for its own layer; then check the layers composite back into the original before you wire them up. Three things learned doing it. Key against MAGENTA for solid objects, but against WHITE for anything thin and metallic. A 1-2px brass lattice over chroma is nothing but blended pixels and no key recovers it, whereas against white the metal is a saturated mid-tone and separates cleanly — and the white halo that leaves behind is invisible when the layer sits over a ceiling. Match the key field to what the layer will sit on. And if you get to CHOOSE the near plane, choose something green: foliage on magenta is the easiest cut there is, because green leaves cannot have both red and blue above green, so every contaminated pixel is identifiable by definition and can be de-spilled at full strength rather than only at the fringe. SET THE COPY IN INK ON A BRIGHT SCENE. White body text does not survive a white kitchen, and there are only two ways out: a panel behind the words, or ink words. Use 'copyTone: \"dark\"' — a panel is a hole punched in the photograph, and this hero's whole argument is that you are looking at a place. It flips the text halo to white as well, because a black shadow under dark type on a light picture just makes mud, and it drops the lead's accent in value, because an accent chosen to sing against a dark room disappears against a white one. IMAGES: three per scene, and the middle one has to be a cut-out. Background is atmosphere and can be busy — it is blurred and sits furthest back. Subject is the cut-out that stands in front of the word. Foreground is the near plane, ideally something soft and out of focus that crosses the bottom of the frame: grass, leaves, bokeh, spray. Any layer with src null renders a placeholder wash tinted from the scene's own two colours — honest about being empty, and not a substitute. This hero is three photographs deep per scene and cannot make its case without them. By default the pointer moves the WORD and nothing else. Every plane following the mouse makes the whole photograph swim, and a photograph of a real room reads as a window — windows do not slosh. Keeping it on the word alone makes the effect a property of the type, which is the one element not pretending to be a thing in the room, while the scroll parallax still carries all four planes. Give a layer a 'tilt' to bring it back, and raise its 'bleed' with it.\n\nSet tilt={0} to drop the pointer response entirely — worth doing on a page that is already busy, and it costs nothing on touch, where there is no pointer to follow anyway.