Innovations
heroes /

Photo Dock (Editorial Monochrome / Fintech)

A full-bleed photograph that DOCKS into a card as you scroll. Over a pinned stage the picture shrinks and rounds onto the centre of the page, white display copy leaves upward, dark copy fades in on the white it uncovers, two sibling cards slide out from behind it and the nav inverts from white to ink. Measured off revolut.com. Monochrome by construction — one blue gradient, on the promo bar, once.

Preview

Source

tsx
"use client";

import { useEffect, useRef } from "react";

/**
 * Photo Dock hero — the full-bleed photograph DOCKS into a card.
 *
 * Measured off revolut.com's homepage rather than eyeballed. Their hero is a
 * 1440x805 stage holding a sky photograph at scale(1.75); across roughly 170px
 * of scroll the photograph collapses into a 228x322 card at radius 20.125px
 * centred on the stage, the white display copy leaves upward, dark copy fades
 * in on the revealed white, and two sibling cards appear either side. The
 * subject never moves off the centre of the frame, which is the whole trick:
 * one object shrinking, not one thing replaced by another.
 *
 * Two deliberate departures from the reference:
 *
 *   1. THIS ONE PINS. Revolut's stage is not sticky, so the entire
 *      choreography spends itself in one trackpad flick and most people never
 *      see it happen. A sticky stage over `dockVh` of extra scroll buys the
 *      transition enough room to read, at the cost of one viewport of page
 *      height. Set dockVh={0} for the reference's un-pinned behaviour.
 *   2. The scroll number is eased, not linear. Linear reads mechanical on a
 *      morph this large.
 *
 * ── THE STATIC STATE IS THE DEFAULT, AND IT IS A REAL PAGE.
 * Every rule that moves or hides anything lives under `.pd.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 section renders as a plain stack: nav,
 * white copy over a full-bleed photograph, then the dock copy and its cards
 * below. NOTHING is at opacity 0 in that state. The animated end-state shows
 * three cards and the static stack shows two, because the third IS the
 * photograph above it — that is the honest fallback, not a missing one.
 */

export interface DockCard {
  /** Photograph. Pass null (or omit) for the built-in CSS sky placeholder. */
  src?: string | null;
  alt?: string;
  /** object-position, for crops whose subject is not dead centre. */
  focus?: string;
  /** Small label above the value — 'Personal', 'Personal · EUR'. */
  label?: string;
  /** The big number. This is the thing the card is really showing. */
  value?: string;
  /** White pill under the value. */
  pill?: string;
  /** Optional white strip along the bottom of the card. */
  footer?: { title: string; meta?: string; amount?: string } | null;
}

export interface PhotoDockHeroProps {
  /** Top-of-page offer strip. The single chromatic surface in this system —
   *  see the note on the gradient below. Omit and no bar renders. */
  promo?: { text: string; link?: { label: string; href: string } } | null;
  promoFrom?: string;
  promoTo?: string;

  brand?: string;
  navLinks?: { label: string; href: string }[];
  navSecondary?: { label: string; href: string } | null;
  navCta?: { label: string; href: string } | null;

  /** Scene A — white, over the photograph. */
  headline?: string;
  sub?: string;
  cta?: { label: string; href: string } | null;

  /** Scene B — ink, on the white the photograph uncovers. */
  dockHeadline?: string;
  dockSub?: string;
  dockCta?: { label: string; href: string } | null;

  /** Strength, 0 to 1, of the wash behind the white copy at full bleed. See
   *  the note on it in the CSS — a photograph with a bright top edge eats
   *  white type completely, and choosing a darker photograph is not always an
   *  option. 0 turns it off for a photograph that does not need it. */
  scrim?: number;

  /** The hero photograph, and the card it becomes. */
  card?: DockCard;
  /** The two that arrive either side of it. Give exactly two, or none. */
  flanks?: DockCard[];

  /** Docked card size in px. The reference lands on 228x322; the default here
   *  is the same 0.708 ratio at a slightly larger size. */
  dockW?: number;
  dockH?: number;
  dockRadius?: number;
  /** Gap between the docked cards. */
  gap?: number;
  /** Flanking cards render at this fraction of the centre card and stay
   *  vertically centred against it. The reference runs about 0.92. */
  flankScale?: number;
  /** Clearance kept between the docked row and the bottom of the stage, in px.
   *  Where the row actually lands is MEASURED, not set — see the layout note in
   *  the effect. This is only the floor it will not cross. */
  dockBottom?: number;
  /** Extra scroll the sticky stage holds for, as a percentage of viewport
   *  height. 0 un-pins the stage and you get the reference's snappier,
   *  easier-to-miss version. */
  dockVh?: number;

  ink?: string;
  bone?: string;
  slate?: string;
}

/* NEUTRAL, NOT BLUE, AND THAT IS THE WHOLE POINT. These started as high-key
   skies, which looked good while all three cards were placeholders and looked
   broken the moment one real photograph went in beside them — a warm interior
   flanked by two blue skies reads as a bug rather than as work in progress.
   Warm greys sit beside anything and read as an empty slot, which is what they
   are. */
/* Mid-tone, not pale. The card's own label and value are white, so a
   placeholder that is nearly white leaves them unreadable and the empty slot
   looks broken rather than empty. */
const HAZES = [
  { a: "#8e8a85", b: "#adaaa5", c: "#cbc8c4" },
  { a: "#99958f", b: "#b6b3ae", c: "#d3d0cc" },
  { a: "#847f7a", b: "#a5a19c", c: "#c4c1bd" },
];

/** A placeholder ground in pure CSS, so the component ships and reads with no
 *  assets at all. It is meant to be replaced: this hero's entire argument is a
 *  photograph holding the eye while it shrinks, and a gradient has no subject
 *  to hold it. */
function sky(i: number) {
  const s = HAZES[i % HAZES.length];
  return [
    "radial-gradient(58% 36% at 22% 72%, rgba(255,255,255,.75), rgba(255,255,255,0) 70%)",
    "radial-gradient(44% 28% at 78% 60%, rgba(255,255,255,.6), rgba(255,255,255,0) 72%)",
    "radial-gradient(74% 42% at 50% 104%, rgba(255,255,255,.85), rgba(255,255,255,0) 66%)",
    `linear-gradient(180deg, ${s.a} 0%, ${s.b} 52%, ${s.c} 100%)`,
  ].join(", ");
}

function CardBody({ card, index }: { card: DockCard; index: number }) {
  return (
    <>
      {card.src ? (
        <img
          className="pd__photo"
          src={card.src}
          alt={card.alt ?? ""}
          style={{ objectPosition: card.focus ?? "50% 50%" }}
        />
      ) : (
        <div className="pd__photo pd__photo--sky" style={{ background: sky(index) }} aria-hidden="true" />
      )}

      <div className="pd__scrim" aria-hidden="true" />

      {(card.label || card.value || card.pill) && (
        <div className={card.footer ? "pd__chrome pd__chrome--lifted" : "pd__chrome"}>
          {card.label && <span className="pd__chrome-label">{card.label}</span>}
          {card.value && <span className="pd__chrome-value">{card.value}</span>}
          {card.pill && <span className="pd__chrome-pill">{card.pill}</span>}
        </div>
      )}

      {card.footer && (
        <div className="pd__strip">
          <span className="pd__strip-dot" aria-hidden="true" />
          <span className="pd__strip-text">
            <strong>{card.footer.title}</strong>
            {card.footer.meta && <em>{card.footer.meta}</em>}
          </span>
          {card.footer.amount && <span className="pd__strip-amount">{card.footer.amount}</span>}
        </div>
      )}
    </>
  );
}

export default function PhotoDockHero({
  promo = {
    text: "Get a £50 bonus when a friend joins.",
    link: { label: "See the offer", href: "#" },
  },
  promoFrom = "#1227fd",
  promoTo = "#6fa0ff",

  brand = "Northwind",
  navLinks = [
    { label: "Personal", href: "#" },
    { label: "Business", href: "#" },
    { label: "Company", href: "#" },
  ],
  navSecondary = { label: "Log in", href: "#" },
  navCta = { label: "Sign up", href: "#" },

  headline = "Banking & Beyond",
  sub = "This is your bank, redefined. Get powerful daily banking and global freedom. Sign up for free in a tap.",
  cta = { label: "Download the app", href: "#" },

  dockHeadline = "Your salary, reimagined",
  dockSub = "Spend smartly, send quickly, sort your salary automatically, and watch your savings grow.",
  dockCta = { label: "Move your salary", href: "#" },

  card = {
    src: "/heroes/photo-dock/owner.webp",
    alt: "A small-business owner checking an order sheet at her packing table",
    label: "Personal",
    value: "£6,012",
    pill: "Accounts",
    footer: { title: "Salary", meta: "Paid today", amount: "+£3,140" },
  },
  flanks = [
    {
      src: null,
      label: "Personal · EUR",
      value: "€3,126",
      pill: "Accounts",
      footer: { title: "Coffee in Paris", meta: "Yesterday, 09:02", amount: "-€3.25" },
    },
    {
      src: null,
      label: "Personal",
      value: "£2,350",
      pill: "Accounts",
      footer: { title: "House bills", meta: "Due today", amount: "-£225" },
    },
  ],

  dockW = 300,
  dockH = 424,
  dockRadius = 22.5,
  gap = 24,
  flankScale = 0.92,
  dockBottom = 40,
  dockVh = 120,
  scrim = 0.42,

  ink = "#1f1f1f",
  bone = "#ffffff",
  slate = "#717173",
}: PhotoDockHeroProps) {
  const rootRef = useRef<HTMLElement | null>(null);
  const stageRef = useRef<HTMLDivElement | 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 copyA = root.querySelector<HTMLElement>(".pd__copy--a");
    const copyB = root.querySelector<HTMLElement>(".pd__copy--b");

    // WHERE THE ROW LANDS IS MEASURED, NOT CHOSEN. The dock copy above it is
    // one, two or three lines depending on the words and the viewport, so any
    // fixed top — a percentage, a centre plus an offset — collides with it on
    // some screen. Both were tried; the CTA ended up printed across the middle
    // card. So: measure the copy, put the row under it, and if what is left
    // will not hold a full-size card, scale the card down rather than letting
    // it run off the bottom. Runs on mount and resize only, never per frame.
    const COPY_B_TOP = 0.15;
    const ROW_GAP = 28;
    const layout = () => {
      const stageH = stage.clientHeight;
      if (!stageH) return;
      const top = stageH * COPY_B_TOP + (copyB?.offsetHeight ?? 0) + ROW_GAP;
      const room = stageH - top - dockBottom;
      const k = room < dockH ? Math.max(0.5, room / dockH) : 1;
      // BOTH GO ON THE ROOT, not the stage. --dw and --dh are derived from
      // --dock-k with var() and are therefore resolved once, on .pd; setting
      // --dock-k any further down the tree changes nothing that has already
      // been substituted. This cost an hour: the card kept coming out
      // full-size while the number said 0.982.
      root.style.setProperty("--dock-top", `${Math.round(top)}px`);
      root.style.setProperty("--dock-k", String(Math.round(k * 1000) / 1000));
    };

    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 overall progress, in JS rather than
    // nested CSS clamp() so the stylesheet stays plain multiplication.
    const seg = (p: number, a: number, b: number) => {
      const v = (p - a) / (b - a);
      return v < 0 ? 0 : v > 1 ? 1 : v;
    };
    const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);

    const tick = () => {
      const p = progress();
      const s = stage.style;
      // THE ORDER IS A SEQUENCE. The white copy is gone before the frame has
      // finished shrinking; the dock copy lands on white that is already
      // white; the flanks come out from behind a card that has stopped moving.
      // Overlap any two of these and the stage reads as three things happening
      // at once rather than one object docking.
      s.setProperty("--a", String(seg(p, 0.0, 0.3)));            // scene A out
      s.setProperty("--d", String(ease(seg(p, 0.05, 0.55))));    // the dock
      s.setProperty("--b", String(seg(p, 0.42, 0.72)));          // scene B in
      s.setProperty("--f", String(ease(seg(p, 0.5, 0.85))));     // flanks out

      copyA?.classList.toggle("is-off", p > 0.22);
      copyB?.classList.toggle("is-off", p < 0.52);
    };

    let queued = false;
    const onResize = () => {
      layout();
      tick();
    };
    const onScroll = () => {
      if (queued) return;
      queued = true;
      requestAnimationFrame(() => {
        queued = false;
        tick();
      });
    };

    // is-motion goes on LAST, after the listeners are 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", onResize);
    root.classList.add("is-motion");
    // After is-motion, because the copy is only absolutely positioned once the
    // class is on and measuring it before that reads the static stack's height.
    layout();

    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onResize);
      root.classList.remove("is-motion");
      root.style.removeProperty("--dock-top");
      root.style.removeProperty("--dock-k");
      copyA?.classList.remove("is-off");
      copyB?.classList.remove("is-off");
    };
  }, []);

  const vars = {
    "--ink": ink,
    "--bone": bone,
    "--slate": slate,
    "--dock-w": `${dockW}px`,
    "--dock-h": `${dockH}px`,
    "--dock-r": `${dockRadius}px`,
    "--gap": `${gap}px`,
    "--flank": String(flankScale),
    "--dock-bottom": `${dockBottom}px`,
    "--scrim": String(scrim),
    "--dock-vh": `${dockVh}svh`,
    "--promo-from": promoFrom,
    "--promo-to": promoTo,
  } as React.CSSProperties;

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

      <div className="pd__stage" ref={stageRef}>
        {promo && (
          <div className="pd__promo">
            <span>{promo.text}</span>
            {promo.link && (
              <a href={promo.link.href} className="pd__promo-link">
                {promo.link.label}
              </a>
            )}
          </div>
        )}

        <header className="pd__nav">
          <span className="pd__brand">{brand}</span>
          <nav className="pd__links">
            {navLinks.map((l) => (
              <a key={l.label} href={l.href}>
                {l.label}
              </a>
            ))}
          </nav>
          <span className="pd__navright">
            {navSecondary && (
              <a href={navSecondary.href} className="pd__navlink">
                {navSecondary.label}
              </a>
            )}
            {navCta && (
              <a href={navCta.href} className="pd__navcta">
                {navCta.label}
              </a>
            )}
          </span>
        </header>

        <div className="pd__copy pd__copy--a">
          <div className="pd__wrap">
            <h1 className="pd__h1">{headline}</h1>
            {sub && <p className="pd__sub">{sub}</p>}
            {cta && (
              <a href={cta.href} className="pd__pill">
                {cta.label}
              </a>
            )}
          </div>
        </div>

        <div className="pd__copy pd__copy--b">
          <div className="pd__wrap pd__wrap--center">
            <h2 className="pd__h2">{dockHeadline}</h2>
            {dockSub && <p className="pd__sub pd__sub--ink">{dockSub}</p>}
            {dockCta && (
              <a href={dockCta.href} className="pd__pill">
                {dockCta.label}
              </a>
            )}
          </div>
        </div>

        {/* The frame comes FIRST so the static stack reads photograph-then-cards
            rather than card, photograph, card. Which side each flank takes is
            --dir, not source order, and paint order is z-index — so nothing in
            the animated state depends on this sequence. */}
        <div className="pd__row">
          <figure className="pd__frame">
            <CardBody card={card} index={0} />
          </figure>

          {flanks[0] && (
            <figure className="pd__flank" style={{ "--dir": "-1" } as React.CSSProperties}>
              <CardBody card={flanks[0]} index={1} />
            </figure>
          )}

          {flanks[1] && (
            <figure className="pd__flank" style={{ "--dir": "1" } as React.CSSProperties}>
              <CardBody card={flanks[1]} index={2} />
            </figure>
          )}
        </div>
      </div>
    </section>
  );
}

const CSS = `
.pd {
  --d: 0; --a: 0; --b: 0; --f: 0;
  --display: 'Aeonik Pro', 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
  --ui: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
  /* The card size actually used. --dock-k is 1 until the effect finds the
     stage too short to hold a full-size card under the copy, and shrinks it. */
  --dock-k: 1;
  --dw: calc(var(--dock-w) * var(--dock-k));
  --dh: calc(var(--dock-h) * var(--dock-k));
  /* Distance from the centre of the stage to the centre of a flanking card. */
  --off: calc((var(--dw) + var(--dw) * var(--flank)) / 2 + var(--gap));
  /* Overwritten in px by the effect's layout pass. This fallback only matters
     for the instant before it runs. */
  --dock-top: 50%;
  position: relative;
  background: var(--bone);
  color: var(--ink);
  font-family: var(--ui);
  -webkit-font-smoothing: antialiased;
}
.pd *, .pd *::before, .pd *::after { box-sizing: border-box; }
/* text-decoration only. An earlier version set 'color: inherit' here too and
   at (0,1,1) it silently out-specified every single-class button rule: the nav
   pill went white-on-white and the dock CTA would have gone ink-on-ink. Links
   that want the ambient colour ask for it by name below. */
.pd a { text-decoration: none; }
.pd__links a, .pd__navlink { color: inherit; }

/* ── The stage. Static by default; sticky only under motion. ───────────── */
.pd__stage { position: relative; }
.pd.is-motion { height: calc(100vh + var(--dock-vh)); height: calc(100svh + var(--dock-vh)); }
.pd.is-motion .pd__stage {
  position: sticky; top: 0;
  height: 100vh; height: 100svh;
  overflow: hidden;
}

/* ── Promo bar. The only chromatic surface in the system, and it appears
      exactly once per page. Never move this gradient onto a button. ─────── */
.pd__promo {
  position: relative; z-index: 4;
  display: flex; align-items: center; justify-content: center; gap: 8px;
  min-height: 40px; padding: 8px 24px;
  background: linear-gradient(to right, var(--promo-from), var(--promo-to));
  color: #fff; font-size: 16px; line-height: 1.2; text-align: center;
}
.pd__promo-link { text-decoration: underline; }

/* ── Nav. Its ground goes from photograph to white as the frame docks, so
      every colour in it interpolates on --d rather than being one value. ── */
.pd__nav {
  position: relative; z-index: 3;
  display: flex; align-items: center; gap: 32px;
  max-width: 1200px; margin: 0 auto; padding: 20px 24px;
  /* INK IS THE DEFAULT, and the interpolation to white only exists under
     motion. Get this backwards and the no-JS page is a white nav on a white
     ground — which is what shipped for about ten minutes. Same for scene A
     below: it is white text because a photograph is behind it, and in the
     static stack there is no photograph behind it. */
  color: var(--ink);
}
.pd.is-motion .pd__nav { color: color-mix(in srgb, var(--ink) calc(var(--d) * 100%), #fff); }
.pd__brand { font-family: var(--display); font-size: 24px; font-weight: 500; letter-spacing: -0.024em; }
.pd__links { display: flex; gap: 32px; font-size: 16px; }
.pd__navright { margin-left: auto; display: flex; align-items: center; gap: 16px; font-size: 16px; }
.pd__navcta {
  padding: 10px 24px; border-radius: 9999px; font-weight: 600;
  background: var(--ink); color: #fff;
}
.pd.is-motion .pd__navcta {
  background: color-mix(in srgb, var(--ink) calc(var(--d) * 100%), #fff);
  color: color-mix(in srgb, #fff calc(var(--d) * 100%), var(--ink));
}
@media (max-width: 780px) { .pd__links, .pd__navlink { display: none; } }

/* ── Copy. Scene A is white over the photograph; scene B is ink on the white
      the photograph uncovers. In the static stack both simply flow. ─────── */
.pd__copy { position: relative; z-index: 2; padding: 40px 0 56px; }
.pd__wrap { max-width: 1200px; margin: 0 auto; padding: 0 24px; }
.pd__wrap--center { text-align: center; }
.pd__copy--a { color: var(--ink); }
.pd.is-motion .pd__copy--a { color: #fff; }
.pd__copy--b { color: var(--ink); }
.pd__h1 {
  margin: 0; font-family: var(--display); font-weight: 500;
  font-size: clamp(44px, 6.1vw, 88px); line-height: 1; letter-spacing: -0.024em;
}
.pd__h2 {
  margin: 0; font-family: var(--display); font-weight: 500;
  font-size: clamp(32px, 3.6vw, 52px); line-height: 1; letter-spacing: -0.012em;
}
.pd__sub {
  margin: 24px 0 0; max-width: 34ch; font-family: var(--display); font-weight: 400;
  font-size: 18px; line-height: 1.33;
}
.pd__wrap--center .pd__sub { max-width: 62ch; margin-left: auto; margin-right: auto; }
.pd__sub--ink { color: var(--slate); }
.pd__pill {
  display: inline-flex; align-items: center; margin-top: 32px;
  min-height: 44px; padding: 0 24px; border-radius: 9999px;
  background: var(--ink); color: #fff; font-size: 16px; font-weight: 600;
}

/* ── The cards. Static: a wrapping flex row, so the frame takes a line of its
      own and the flanks sit under it. Motion: all three go absolute. ────── */
.pd__row {
  display: flex; flex-wrap: wrap; justify-content: center; align-items: flex-start;
  gap: var(--gap); padding: 0 24px 80px;
}
.pd__frame, .pd__flank {
  position: relative; margin: 0; overflow: hidden;
  background: #dbe9f7; border-radius: var(--dock-r);
}
.pd__frame { width: 100%; height: 70vh; height: 70svh; border-radius: 0; }
.pd__flank { width: var(--dw); height: var(--dh); }

/* z-index: 1 here is load-bearing twice over. It puts the whole row BENEATH the
   copy — scene A is white type that has to sit on the photograph, and giving
   the frame a z-index without this line raised the card above it and swallowed
   the headline whole. And because it is a z-index on a positioned element it
   opens a stacking context, so the frame/flank order below is contained to the
   row instead of competing with the copy. */
.pd.is-motion .pd__row { position: absolute; inset: 0; display: block; padding: 0; z-index: 1; }
/* The flanks slide out from beneath the docked card, so the card has to paint
   over them. Source order used to do this and did it asymmetrically — the
   right-hand flank sat on top of the frame. */
.pd.is-motion .pd__frame { z-index: 2; }
.pd.is-motion .pd__flank { z-index: 1; }
.pd.is-motion .pd__frame {
  position: absolute; left: 50%; transform: translateX(-50%);
  top: calc(var(--dock-top) * var(--d));
  /* One length interpolating between the stage and the card. At --d 0 both
     resolve to 100%; at 1, to the card. The frame is out of flow, so paying
     for layout on it costs nothing else on the page. */
  width: calc(100% - (100% - var(--dw)) * var(--d));
  height: calc(100% - (100% - var(--dh)) * var(--d));
  border-radius: calc(var(--dock-r) * var(--d));
}
.pd.is-motion .pd__flank {
  position: absolute; left: 50%;
  top: calc(var(--dock-top) + (var(--dh) - var(--dh) * var(--flank)) / 2);
  width: calc(var(--dw) * var(--flank));
  height: calc(var(--dh) * var(--flank));
  opacity: var(--f);
  /* They come OUT FROM BEHIND the docked card rather than in from the edges.
     Sliding them in from off-stage reads as two new things arriving; sliding
     them out from under it reads as one card becoming three. */
  transform: translateX(calc(-50% + var(--off) * var(--f) * var(--dir)));
}

/* CARD CONTENTS DO NOT SCALE WITH THEIR CARD unless they are told to, and the
   card can end up anywhere from the whole viewport down to 215px wide. Two
   factors, because the chrome and the strip do not shrink for the same reason:
   the chrome is huge at full bleed and shrinks as the card docks (--d), while
   the strip only ever exists at the docked size and shrinks only when a short
   viewport forces the card down (--dock-k). Tie the strip to the chrome's
   factor and it comes out at 10px on a full-size card. */
.pd__frame, .pd__flank { --cs: 1; --ss: 1; }
.pd__flank { --cs: 0.66; --ss: var(--flank); }
.pd.is-motion .pd__frame {
  --cs: calc(var(--dock-k) * (1 - 0.34 * var(--d)));
  --ss: max(0.68, var(--dock-k));
}
.pd.is-motion .pd__flank {
  --cs: calc(var(--dock-k) * var(--flank) * 0.66);
  --ss: max(0.68, calc(var(--dock-k) * var(--flank)));
}

/* WHITE TYPE OVER A PHOTOGRAPH IS A CONTRAST PROBLEM, NOT A STYLE CHOICE, and
   it is the one thing that decides whether this hero works. The reference gets
   away with no wash because its photograph is a mid-blue sky behind every word;
   point the same layout at a bright interior and the headline, the sub and the
   whole nav disappear into a pale wall. Measured, not guessed: the first real
   photograph put through this hero rendered white 88px type over a ~200/255
   wall, which is invisible.

   So: a top-weighted wash, inside the frame so it clips and docks with the
   photograph, gone by the time the card lands because nothing white sits over
   the photograph any more. Set scrim={0} for a picture that does not need it,
   and raise it for one that needs more. It is deliberately top-weighted rather
   than flat — the bottom of the frame carries no copy and does not want
   darkening. */
.pd__scrim {
  position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(
    180deg,
    rgba(0, 0, 0, var(--scrim)) 0%,
    rgba(0, 0, 0, calc(var(--scrim) * 0.5)) 32%,
    rgba(0, 0, 0, 0) 62%
  );
}
.pd.is-motion .pd__scrim { opacity: calc(1 - var(--d)); }
/* The flanks never carry copy, so they never carry a wash. */
.pd__flank .pd__scrim { display: none; }

.pd__photo { display: block; width: 100%; height: 100%; object-fit: cover; }
.pd.is-motion .pd__photo { transform: scale(calc(1 + 0.08 * (1 - var(--d)))); }

/* ── Card chrome. At full bleed this is a phone-sized outline laid over the
      photograph; docked, it is the card's own overlay. ONE element doing both,
      because two cross-fading copies never quite line up. ───────────────── */
.pd__chrome {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  width: 336px; height: 475px; max-width: 92%; max-height: 88%;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px;
  border-radius: 20px; color: #fff; text-align: center;
  /* Two shadows, not one: a tight dark halo that holds an edge against a light
     hoodie or a white wall, and a wide soft one for overall separation. The
     single soft shadow this replaced was tuned against a flat sky and vanished
     over a real photograph. */
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 2px 22px rgba(0, 0, 0, 0.3);
  transform: translate(-50%, calc(-50% - var(--lift, 0px))) scale(var(--cs));
}
/* A card with a strip along the bottom has less room above it than below, so
   dead-centre puts the pill on the strip. Half the strip's height back. */
.pd__chrome--lifted { --lift: calc(26px * var(--ss)); }
.pd.is-motion .pd__chrome {
  border: 1px solid rgba(255, 255, 255, calc(0.5 * (1 - var(--d))));
}
.pd__chrome-label { font-size: 16px; }
.pd__chrome-value { font-family: var(--display); font-weight: 500; font-size: 52px; line-height: 1; letter-spacing: -0.012em; }
.pd__chrome-pill {
  margin-top: 4px; padding: 8px 20px; border-radius: 9999px;
  background: #fff; color: var(--ink); font-size: 14px; font-weight: 600; text-shadow: none;
}
.pd__strip {
  position: absolute;
  left: calc(10px * var(--ss)); right: calc(10px * var(--ss)); bottom: calc(10px * var(--ss));
  display: flex; align-items: center; gap: calc(10px * var(--ss));
  padding: calc(10px * var(--ss)) calc(12px * var(--ss));
  border-radius: calc(16px * var(--ss)); background: #fff; color: var(--ink);
}
.pd.is-motion .pd__strip { opacity: var(--d); }
.pd__strip-dot {
  width: calc(28px * var(--ss)); height: calc(28px * var(--ss));
  flex: none; border-radius: 9999px; background: var(--promo-from);
}
.pd__strip-text { min-width: 0; display: flex; flex-direction: column; line-height: 1.25; text-align: left; }
/* The row is one line each. A wrapped 'Coffee in Paris' over two lines pushes
   the strip taller than the space it was given and reads as a bug. */
.pd__strip-text strong, .pd__strip-text em, .pd__strip-amount {
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.pd__strip-text strong { font-size: calc(14px * var(--ss)); font-weight: 600; }
.pd__strip-text em { font-style: normal; font-size: calc(12px * var(--ss)); color: var(--slate); }
.pd__strip-amount { margin-left: auto; flex: none; font-size: calc(14px * var(--ss)); font-weight: 600; }

/* ── Motion placement of the two copy blocks. ─────────────────────────── */
.pd.is-motion .pd__copy { position: absolute; left: 0; right: 0; padding: 0; }
.pd.is-motion .pd__copy--a {
  top: 22%;
  opacity: calc(1 - var(--a));
  transform: translate3d(0, calc(var(--a) * -56px), 0);
}
.pd.is-motion .pd__copy--b {
  /* 15% here and COPY_B_TOP in the effect are the same number. Change one and
     the row lands in the wrong place. */
  top: 15%;
  opacity: var(--b);
  transform: translate3d(0, calc((1 - var(--b)) * 24px), 0);
}
/* A faded block still swallows clicks meant for the one under it, and opacity
   does nothing about that on its own. The class is toggled from the same tick
   that sets the numbers, so it can never disagree with what is on screen. */
.pd.is-motion .pd__copy.is-off { pointer-events: none; }

@media (max-width: 780px) {
  .pd__frame { height: 62svh; }
  .pd__chrome-value { font-size: 40px; }
  .pd.is-motion .pd__copy--a { top: 26%; }
}

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

CLI Install

npx innovations add photo-dock

Where to use it

Use this when a brand has ONE photograph worth the whole screen and a second thing to say underneath it. Banking, fintech, insurance, travel, premium services, anything editorial-monochrome. It is the wrong hero for a business with weak photography — the entire idea is that a picture is good enough to hold the eye while it shrinks. In Astro: --- import PhotoDockHero from '../components/innovations/heroes/photo-dock'; --- <PhotoDockHero client:load /> It needs client:load or client:visible. Server-rendered alone you get the static stack described below, which is a real page rather than a broken one. THE MECHANIC, AND WHY IT IS ONE OBJECT AND NOT TWO. A sticky stage holds while 'dockVh' of extra scroll goes past. Across that scroll a single figure interpolates from filling the stage to a 300x424 card at 22.5px radius, centred, sitting below the second headline. The photograph inside it is object-fit: cover in both states, so the crop tightens onto the middle rather than the picture being swapped for a smaller one. The subject must therefore be CENTRED and must survive being cropped to a 0.708 portrait — a wide landscape or a subject placed at one edge will slide out of frame as it docks and the whole effect collapses. Use 'focus' on the card to move the crop centre if it is close but not exact. THE ORDER OF THE FOUR NUMBERS IS THE DESIGN, not four numbers that happen to overlap: · --a, scene A leaving, 0 to 0.30 · --d, the dock itself, 0.05 to 0.55, eased · --b, scene B arriving, 0.42 to 0.72 · --f, the flanks coming out, 0.50 to 0.85 Scene A is gone before the frame stops moving; scene B lands on white that has already gone white; the flanks emerge from behind a card that has already stopped. Widen any range until two of those overlap and the stage stops reading as one object docking and starts reading as three things happening at once. THE FLANKS COME OUT FROM BEHIND THE CARD, not in from the edges. They start at translateX(0), hidden under the docked frame, and slide outward. Sliding them in from off-stage was tried and reads as two new things arriving; sliding them out from under it reads as one card becoming three. Keep the direction. THIS PINS AND THE REFERENCE DOES NOT. Revolut spends the entire choreography in about 170px of scroll, which is one trackpad flick, and most visitors never see it happen. The default dockVh of 120 buys the transition room to read and costs one viewport of page height. Set dockVh={0} for the reference's snappier, easier-to-miss behaviour — the stage stops being sticky and everything else still works. THE STATIC STATE IS THE DEFAULT AND IT IS A REAL PAGE. Every rule that moves or hides anything is under '.pd.is-motion', and the effect adds that class LAST, after the listeners are attached and one tick has run. With no JS, a thrown error, or prefers-reduced-motion you get: nav, white copy over a full-bleed photograph, then the dock copy and its cards below, nothing at opacity 0. Note the animated end-state shows three cards and the static stack shows two — the third IS the photograph above it. That is the honest fallback, not a missing card. Do not "fix" it by adding a duplicate. COLOUR IS THE ONE RULE THIS SYSTEM WILL NOT BEND ON. Seven neutrals and a single blue gradient. That gradient belongs to the promo bar and appears once per page — put it on a button or a card and it stops meaning anything. All the colour in the design is supposed to come from the photographs. The nav is the one place a colour interpolates, from white to ink on --d, because its ground genuinely changes under it as the photograph uncovers the page. Type: display face at weight 500 and never heavier, tracking tightening as it grows (-0.024em at the h1, -0.012em at the h2). Authority comes from size, not weight. Controls are pills at 9999px, with no exceptions and no shadows anywhere — separation is surface colour and hairlines. PHOTOGRAPHS ARE NOT SUPPLIED. Pass src: null on any card and it renders a high-key CSS sky instead, which is on-palette and needs no assets, but it is a placeholder: a gradient has no subject to hold the eye while it shrinks, so the hero cannot make its argument until real pictures are in. Shoot or source three: one centred portrait with generous space above the head for the headline, and two supporting frames that read at card size.