// Shared-element scroll morphs between sections.
// Real elements (e.g. 6 bento cards in section 02) literally fly out and land
// as the real destination elements (3 pillars in section 03). Each zone uses
// a for scroll runway; is one fixed overlay that
// renders twin elements interpolated from src bounding rects to dst rects.
//
// Zone 23 → 6 bento cards → 3 OS pillars
// Zone 34 → 6 mini rungs → 6 ladder rungs
// Zone 45 → 6 ladder rungs → 6 capability wedges (out of 8)
//
// CSS variables --p23 / --p34 / --p45 are driven continuously by scroll so the
// surrounding sections can fade + blur their content in/out around the morph.
function MorphSpacer({ id, vh = 90 }) {
return (
);
}
/* ---------- math ---------- */
const _clamp01 = v => Math.max(0, Math.min(1, v));
const _lerp = (a, b, t) => a + (b - a) * t;
const _easeInOut = t => t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t + 2, 2) / 2;
const _easeOut = t => 1 - Math.pow(1 - t, 3);
const _easeIn = t => t * t;
/* ---------- 02 → 03 mapping ----------
6 bento cards consolidate into 3 slabs, paired by colour family.
Each pair has a "lead" (takes on the slab clip + gradient at the end)
and a "follower" (fades out mid-flight as it converges on the lead). */
const T23_CARDS = [
{ lead: true, dst: 0, bg: 'linear-gradient(155deg, #0D903A 0%, #074c2a 100%)' },
{ lead: true, dst: 1, bg: 'linear-gradient(135deg, #1a3552 0%, #06192e 100%)' },
{ lead: false, dst: 0, bg: 'linear-gradient(155deg, #d8efde 0%, #b3e2c0 100%)' },
{ lead: false, dst: 1, bg: 'linear-gradient(155deg, #dde9f7 0%, #b8cfe9 100%)' },
{ lead: true, dst: 2, bg: 'linear-gradient(155deg, #ffe28a 0%, #ffbb40 100%)' },
{ lead: false, dst: 2, bg: 'linear-gradient(135deg, #0f1620 0%, #1e2433 100%)' },
];
const T23_SLAB_CLIPS = [
'polygon(0% 0%, 100% 0%, 86% 100%, 0% 100%)',
'polygon(14% 0%, 100% 0%, 86% 100%, 0% 100%)',
'polygon(14% 0%, 100% 0%, 100% 100%, 0% 100%)',
];
const T23_SLAB_GRADS = [
['#0D903A', '#074c2a'],
['#246BB2', '#164A97'],
['#FFBA00', '#8C6100'],
];
/* ---------- 03 → 04 mapping ----------
6 mini rungs inside pillar 01 (labels: Case / Sim / Mimic / Shadow /
Supervise / Independent) → 6 actual ladder rungs in section 04 by index.
Pillars 02 + 03 fade out via CSS — they were context, the ladder is the act. */
/* ---------- 04 → 05 mapping ----------
6 ladder rungs orbit through the wheel centre and settle as 6 of the
8 capability wedges. The 2 remaining wedges (Value, Reactivation) appear
independently via the section's --p45 fade-in. */
const T45_CAPS = [
{ idx: 0, color1: '#0D903A', color2: '#074c2a' }, // Finance (Case)
{ idx: 1, color1: '#0F8E42', color2: '#063e1f' }, // Business (Sim)
{ idx: 2, color1: '#246BB2', color2: '#164A97' }, // Macro (Mimic)
{ idx: 3, color1: '#1A549F', color2: '#0e3669' }, // Products (Shadow)
{ idx: 5, color1: '#19519C', color2: '#0e3669' }, // Acquisition (Supervise)
{ idx: 7, color1: '#057430', color2: '#03461e' }, // Execution (Independent)
];
function MorphLayer() {
const [, setTick] = React.useState(0);
const stateRef = React.useRef({
z23: { p: 0, src: [], dst: [] },
z34: { p: 0, src: [], dst: [] },
z45: { p: 0, src: [], dst: [] },
});
// useLayoutEffect → variables set before first paint so users landing
// mid-page don't see a flash of mis-staged sections.
React.useLayoutEffect(() => {
let raf = 0;
const zones = [
{ key: 'z23', spacer: 'morph-23', cssVar: '--p23',
srcSel: '.bento > .bento-card',
dstSel: '.os-slabs .os-slab',
srcN: 6, dstN: 3 },
{ key: 'z34', spacer: 'morph-34', cssVar: '--p34',
srcSel: '.os-slab--ladder .os-slab-rung',
dstSel: '.ladder-3d .rung',
srcN: 6, dstN: 6 },
{ key: 'z45', spacer: 'morph-45', cssVar: '--p45',
srcSel: '.ladder-3d .rung',
dstSel: '.caprich-wedge',
srcN: 6, dstN: 8 },
];
function measure() {
raf = 0;
const viewH = window.innerHeight;
const next = {};
for (const z of zones) {
const spacer = document.getElementById(z.spacer);
if (!spacer) { next[z.key] = { p: 0, src: [], dst: [] }; continue; }
const sr = spacer.getBoundingClientRect();
const total = sr.height + viewH;
const passed = viewH - sr.top;
const p = _clamp01(passed / total);
document.documentElement.style.setProperty(z.cssVar, p.toFixed(4));
const srcEls = document.querySelectorAll(z.srcSel);
const dstEls = document.querySelectorAll(z.dstSel);
if (srcEls.length !== z.srcN || dstEls.length !== z.dstN) {
next[z.key] = { p, src: [], dst: [] };
continue;
}
const src = [], dst = [];
for (let i = 0; i < srcEls.length; i++) src.push(srcEls[i].getBoundingClientRect());
for (let i = 0; i < dstEls.length; i++) dst.push(dstEls[i].getBoundingClientRect());
next[z.key] = { p, src, dst };
}
stateRef.current = next;
setTick(t => t + 1);
}
function schedule() { if (!raf) raf = requestAnimationFrame(measure); }
measure();
window.addEventListener('scroll', schedule, { passive: true });
window.addEventListener('resize', schedule);
return () => {
window.removeEventListener('scroll', schedule);
window.removeEventListener('resize', schedule);
if (raf) cancelAnimationFrame(raf);
};
}, []);
const { z23, z34, z45 } = stateRef.current;
return (
);
}
function MorphZone({ show, p, children }) {
if (!show || p <= 0.001 || p >= 0.999) return null;
// Crossfade against real source/dest at the ends of the zone.
// Wider in/out windows so twins overlap longer with the real elements
// and there's no visible pop on either side.
const fade = _easeInOut(_clamp01((p - 0.06) / 0.18))
* (1 - _easeInOut(_clamp01((p - 0.84) / 0.14)));
return (
{children}
);
}
/* ============================================================
02 → 03 — 6 bento cards consolidate into 3 pillars
============================================================ */
function T23Twins({ p, src, dst }) {
const tLead = _easeInOut(p);
const tFollower = _easeInOut(_clamp01(p / 0.65));
// Compute every lead's interpolated rect so followers chase the moving lead.
const leadRects = {};
for (let i = 0; i < T23_CARDS.length; i++) {
const c = T23_CARDS[i];
if (!c.lead) continue;
const sr = src[i], dr = dst[c.dst];
leadRects[c.dst] = {
x: _lerp(sr.left, dr.left, tLead),
y: _lerp(sr.top, dr.top, tLead),
w: _lerp(sr.width, dr.width, tLead),
h: _lerp(sr.height,dr.height,tLead),
};
}
const ordered = T23_CARDS
.map((c, i) => ({ ...c, i }))
.sort((a, b) => (a.lead ? 1 : 0) - (b.lead ? 1 : 0));
return ordered.map((c) => {
const i = c.i;
const sr = src[i];
const dr = dst[c.dst];
const slabClip = T23_SLAB_CLIPS[c.dst];
const [g1, g2] = T23_SLAB_GRADS[c.dst];
let x, y, w, h, op, clip, bg, radius, shadow;
if (c.lead) {
x = _lerp(sr.left, dr.left, tLead);
y = _lerp(sr.top, dr.top, tLead);
w = _lerp(sr.width, dr.width, tLead);
h = _lerp(sr.height,dr.height,tLead);
const blend = _easeOut(_clamp01((tLead - 0.30) / 0.55));
bg = blend < 0.02
? c.bg
: `linear-gradient(155deg,
color-mix(in oklab, ${g1} ${blend*100}%, ${_extractStop(c.bg, 0)}) 0%,
color-mix(in oklab, ${g2} ${blend*100}%, ${_extractStop(c.bg, 1)}) 100%)`;
const clipBlend = _clamp01((tLead - 0.55) / 0.40);
clip = clipBlend < 0.02
? 'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)'
: _interpolateClip(
'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)',
slabClip,
_easeInOut(clipBlend)
);
radius = _lerp(24, 0, _easeOut(tLead));
shadow = `0 ${_lerp(20, 50, tLead)}px ${_lerp(40, 120, tLead)}px rgba(5,10,20,${_lerp(0.18, 0.55, tLead)})`;
op = 1;
} else {
const leadR = leadRects[c.dst];
x = _lerp(sr.left, leadR.x, tFollower);
y = _lerp(sr.top, leadR.y, tFollower);
w = _lerp(sr.width, leadR.w, tFollower);
h = _lerp(sr.height,leadR.h, tFollower);
bg = c.bg;
clip = 'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)';
radius = _lerp(24, 18, tFollower);
op = Math.max(0, 1 - _easeIn(tFollower) * 1.15);
shadow = `0 ${_lerp(14, 30, tFollower)}px ${_lerp(28, 60, tFollower)}px rgba(5,10,20,${_lerp(0.14, 0.0, tFollower)})`;
}
return (
);
});
}
/* ============================================================
03 → 04 — 6 mini rungs → 6 ladder rungs
============================================================ */
function T34Twins({ p, src, dst }) {
const tBase = _easeInOut(p);
return src.map((sr, i) => {
const dr = dst[i];
// Subtle stagger — top mini rung leaves slightly later so the stack
// unfurls "from the bottom up" into the ladder.
const stagger = i * 0.05;
const t = _easeInOut(_clamp01((p - stagger) / (1 - stagger * 1.2)));
const x = _lerp(sr.left, dr.left, t);
const y = _lerp(sr.top, dr.top, t);
const w = _lerp(sr.width, dr.width, t);
const h = _lerp(sr.height,dr.height,t);
const radius = _lerp(8, 14, t);
// Source mini-rung look (white tile over green slab) →
// destination ladder plate (deep dark with gold trim).
const blend = _easeOut(_clamp01((t - 0.20) / 0.55));
const bg = `linear-gradient(155deg,
color-mix(in oklab, #0c1424 ${blend*100}%, rgba(255,255,255,0.10)) 0%,
color-mix(in oklab, #1d2940 ${blend*100}%, rgba(255,255,255,0.04)) 100%)`;
const borderC = `rgba(255,186,0, ${_lerp(0.0, 0.35, blend).toFixed(2)})`;
const shadow = `0 ${_lerp(2, 20, t).toFixed(1)}px ${_lerp(8, 50, t).toFixed(1)}px rgba(5,10,20,${_lerp(0.10, 0.45, t).toFixed(2)}),
inset 0 1px 0 rgba(255,255,255,${_lerp(0.10, 0.20, t).toFixed(2)})`;
// Small accent line at the bottom — mirrors the ::after gold line on
// the real ladder rungs — appears toward the end of the morph.
const accentOp = _easeOut(_clamp01((t - 0.55) / 0.40));
return (
);
});
}
/* ============================================================
04 → 05 — 6 ladder rungs orbit into 6 capability wedges
============================================================ */
function T45Twins({ p, src, dst }) {
const t = _easeInOut(p);
// Wheel centre, derived from the average of all wedge bboxes.
let cx = 0, cy = 0, n = 0;
for (const dr of dst) {
cx += dr.left + dr.width / 2;
cy += dr.top + dr.height / 2;
n++;
}
cx /= n; cy /= n;
return src.map((sr, i) => {
const cap = T45_CAPS[i];
const dr = dst[cap.idx];
// Stagger so the bottom rung leads — feels like an unfurling vortex.
const stagger = i * 0.04;
const u = _easeInOut(_clamp01((p - stagger) / (1 - stagger * 1.2)));
const startX = sr.left + sr.width / 2;
const startY = sr.top + sr.height / 2;
const endX = dr.left + dr.width / 2;
const endY = dr.top + dr.height / 2;
// Quadratic bezier with control point pulled toward wheel centre —
// gives every rung a curved orbital trajectory.
const ctrlX = _lerp((startX + endX) / 2, cx, 0.45);
const ctrlY = _lerp((startY + endY) / 2, cy, 0.45);
const px = (1-u)*(1-u)*startX + 2*(1-u)*u*ctrlX + u*u*endX;
const py = (1-u)*(1-u)*startY + 2*(1-u)*u*ctrlY + u*u*endY;
// Size lerp from rung dims (horizontal plate) → wedge dims (small square-ish).
const w = _lerp(sr.width, Math.min(dr.width, dr.height), u);
const h = _lerp(sr.height,Math.min(dr.width, dr.height), u);
const x = px - w / 2;
const y = py - h / 2;
const radius = _lerp(14, 200, _easeOut(u)); // rectangle → almost-circle
const blend = _easeOut(_clamp01((u - 0.25) / 0.55));
const bg = `linear-gradient(135deg,
color-mix(in oklab, ${cap.color1} ${blend*100}%, #FFBA00) 0%,
color-mix(in oklab, ${cap.color2} ${blend*100}%, #8C6100) 100%)`;
// Slight rotation toward the wedge's outward direction
const dx = endX - cx, dy = endY - cy;
const angle = Math.atan2(dy, dx) * 180 / Math.PI + 90;
const rot = _lerp(0, angle, u);
return (
);
});
}
/* ---------- helpers ---------- */
// Extract a representative colour stop from a CSS gradient string.
function _extractStop(grad, idx) {
const m = grad.match(/#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)/g);
if (!m || !m.length) return grad;
return m[Math.min(idx, m.length - 1)];
}
// Interpolate two polygon() clip-paths point-by-point.
function _interpolateClip(a, b, t) {
const parse = s => {
const inner = s.match(/polygon\(([^)]+)\)/);
if (!inner) return null;
return inner[1].split(',').map(pt => {
const [x, y] = pt.trim().split(/\s+/);
return [parseFloat(x), parseFloat(y)];
});
};
const pa = parse(a), pb = parse(b);
if (!pa || !pb || pa.length !== pb.length) return b;
const pts = pa.map((p, i) => [
_lerp(p[0], pb[i][0], t),
_lerp(p[1], pb[i][1], t),
]);
return 'polygon(' + pts.map(p => `${p[0]}% ${p[1]}%`).join(', ') + ')';
}
Object.assign(window, { MorphSpacer, MorphLayer });