fix(story): filmstrip de prints com sticky+scrub dedado e ReelDirector pos-hidratacao sem mutation no DOM
This commit is contained in:
1 parent
ecadb7c540
commit
a3d080d4d0
2 files changed
+250
-39
No files matched your search
@@ -1,14 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { DrawLine } from "../animations/DrawLine";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import HorizontalScroll from "../cinematic/HorizontalScroll";
|
|
||||||
import { Section } from "../Section";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Palco extra estilo product-page: filmstrip horizontal PINNADO com prints
|
* Palco 2 do estilo product-page: filmstrip horizontal dirigido pelo scroll.
|
||||||
* reais capturados dos deployments em produção (public/assets/prints).
|
* Arquitetura: runway 280vh + palco `position: sticky` (mesma técnica provada
|
||||||
* O scroll vertical empurra a faixa na horizontal (scrub), cada card entra
|
* no reel do hero — sem GSAP pin, sem wrapper de flex). O scrub move a faixa
|
||||||
* com rotateY/scale ligados ao mesmo trilho — sensação de carrossel de vídeo.
|
* da direita para a esquerda; quando a faixa termina, o sticky solta e o
|
||||||
|
* scroll continua para baixo normalmente. Role para cima = rebobina.
|
||||||
|
*
|
||||||
|
* Sem JS / reduced motion: grid estático (fallback), como no ScrollStory.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const PRINTS = [
|
const PRINTS = [
|
||||||
@@ -42,28 +43,189 @@ const PRINTS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const CARD_W = "min(80vw, 430px)";
|
||||||
|
|
||||||
export default function PrintReel() {
|
export default function PrintReel() {
|
||||||
|
const sectionRef = useRef<HTMLElement>(null);
|
||||||
|
const [engine, setEngine] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches)
|
||||||
|
return undefined;
|
||||||
|
setEngine(true);
|
||||||
|
return undefined;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!engine) return undefined;
|
||||||
|
const section = sectionRef.current;
|
||||||
|
if (!section) return undefined;
|
||||||
|
|
||||||
|
let cleanup = () => {};
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const setup = async () => {
|
||||||
|
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
||||||
|
import("gsap"),
|
||||||
|
import("gsap/ScrollTrigger"),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
const track = section.querySelector<HTMLElement>("[data-strip-track]");
|
||||||
|
const idxEl = section.querySelector<HTMLElement>("[data-strip-index]");
|
||||||
|
const bar = section.querySelector<HTMLElement>("[data-strip-bar]");
|
||||||
|
if (!track) return;
|
||||||
|
|
||||||
|
// distância = largura da faixa menos a viewport
|
||||||
|
const distance = () =>
|
||||||
|
Math.max(0, track.scrollWidth - window.innerWidth);
|
||||||
|
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
defaults: { ease: "none" },
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: section,
|
||||||
|
start: "top top",
|
||||||
|
end: "bottom bottom",
|
||||||
|
scrub: 0.85,
|
||||||
|
invalidateOnRefresh: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
tl.to(track, { x: () => -distance(), duration: 1 });
|
||||||
|
if (bar) tl.fromTo(bar, { scaleX: 0 }, { scaleX: 1, duration: 1 }, 0);
|
||||||
|
if (idxEl) {
|
||||||
|
tl.eventCallback("onUpdate", () => {
|
||||||
|
const p = (tl.scrollTrigger?.progress ?? 0) * PRINTS.length;
|
||||||
|
idxEl.textContent = String(
|
||||||
|
Math.min(PRINTS.length, Math.floor(p) + 1),
|
||||||
|
).padStart(2, "0");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// cada card "acende" ao entrar e esmaece ao passar (scrub, reversível)
|
||||||
|
const cards = Array.from(
|
||||||
|
section.querySelectorAll<HTMLElement>("[data-strip-card]"),
|
||||||
|
);
|
||||||
|
const span = 1 / cards.length;
|
||||||
|
cards.forEach((card, i) => {
|
||||||
|
tl.fromTo(
|
||||||
|
card,
|
||||||
|
{ opacity: 0.15, scale: 0.92, rotateY: -9 },
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
rotateY: 0,
|
||||||
|
transformPerspective: 1100,
|
||||||
|
duration: span * 0.5,
|
||||||
|
},
|
||||||
|
Math.max(0, i * span - span * 0.12),
|
||||||
|
);
|
||||||
|
if (i > 0) {
|
||||||
|
tl.to(
|
||||||
|
card,
|
||||||
|
{ opacity: 0.35, scale: 0.94, duration: span * 0.4 },
|
||||||
|
(i - 1) * span + span * 0.55,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, section);
|
||||||
|
|
||||||
|
cleanup = () => ctx.revert();
|
||||||
|
};
|
||||||
|
|
||||||
|
void setup();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
}, [engine]);
|
||||||
|
|
||||||
|
// ---- Fallback estático (sem JS / reduced motion) ----
|
||||||
|
if (!engine) {
|
||||||
return (
|
return (
|
||||||
<Section
|
<section
|
||||||
id="print-reel"
|
id="print-reel"
|
||||||
eyebrow="No ar, rodando"
|
className="border-t border-[rgba(255,255,255,.06)] bg-[var(--ink)] py-16 text-[var(--paper)]"
|
||||||
title="Prints ao vivo dos sistemas em produção"
|
|
||||||
description="Capturas reais dos deployments — role e a faixa corre na horizontal como um reel."
|
|
||||||
dark
|
|
||||||
disableChildrenAnimation
|
|
||||||
>
|
>
|
||||||
<div className="flex h-[100dvh] items-center">
|
<div className="mx-auto w-full max-w-[1240px] px-6">
|
||||||
<HorizontalScroll className="w-full">
|
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--slash)]">
|
||||||
|
No ar, rodando
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-3 font-display text-[clamp(1.6rem,4vw,2.4rem)] font-extrabold tracking-[-0.02em]">
|
||||||
|
Prints ao vivo dos sistemas em produção
|
||||||
|
</h2>
|
||||||
|
<div className="mt-8 grid gap-6 sm:grid-cols-2">
|
||||||
{PRINTS.map((print) => (
|
{PRINTS.map((print) => (
|
||||||
<a
|
<a
|
||||||
key={print.name}
|
key={print.name}
|
||||||
data-scroll-item
|
|
||||||
href={print.url}
|
href={print.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="group flex w-[min(78vw,380px)] shrink-0 flex-col"
|
className="block border border-[rgba(255,255,255,.1)] bg-[rgba(255,255,255,.03)]"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={print.src}
|
||||||
|
alt={`Captura do projeto ${print.name} em produção`}
|
||||||
|
className="h-72 w-full object-cover object-top"
|
||||||
|
/>
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="font-display text-[18px] font-extrabold">
|
||||||
|
{print.name}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[13px] leading-6 text-[rgba(244,242,237,.66)]">
|
||||||
|
{print.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Modo reel: runway + sticky ----
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="print-reel"
|
||||||
|
ref={sectionRef}
|
||||||
|
data-strip
|
||||||
|
className="relative h-[280vh] bg-[var(--ink)]"
|
||||||
|
aria-label="Galeria horizontal dos projetos em produção"
|
||||||
|
>
|
||||||
|
<div className="sticky top-0 flex h-[100dvh] w-full flex-col overflow-hidden">
|
||||||
|
{/* cabeçalho do palco */}
|
||||||
|
<div className="mx-auto flex w-full max-w-[1240px] shrink-0 flex-wrap items-end justify-between gap-4 px-6 pt-sp-16 lg:px-16">
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--slash)]">
|
||||||
|
No ar, rodando
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-2 font-display text-[clamp(1.6rem,3.4vw,2.6rem)] font-extrabold uppercase leading-[1] tracking-[-0.03em] text-[var(--paper)]">
|
||||||
|
Prints ao vivo da produção
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.5)]">
|
||||||
|
← role para percorrer →
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* faixa */}
|
||||||
|
<div className="flex min-h-0 flex-1 items-center">
|
||||||
|
<div
|
||||||
|
data-strip-track
|
||||||
|
className="flex w-max items-stretch gap-10 pl-[max(1.5rem,calc((100vw-1240px)/2+2rem))] pr-[max(1.5rem,calc((100vw-1240px)/2+2rem))] will-change-transform"
|
||||||
|
>
|
||||||
|
{PRINTS.map((print, i) => (
|
||||||
|
<a
|
||||||
|
key={print.name}
|
||||||
|
data-strip-card
|
||||||
|
href={print.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="group flex shrink-0 flex-col will-change-transform"
|
||||||
|
style={{ width: CARD_W }}
|
||||||
>
|
>
|
||||||
{/* moldura de navegador */}
|
|
||||||
<div className="overflow-hidden rounded-t-xl border border-b-0 border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.05)] px-4 py-3">
|
<div className="overflow-hidden rounded-t-xl border border-b-0 border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.05)] px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-[rgba(255,255,255,.18)]" />
|
<span className="h-2.5 w-2.5 rounded-full bg-[rgba(255,255,255,.18)]" />
|
||||||
@@ -72,38 +234,57 @@ export default function PrintReel() {
|
|||||||
className={`h-2.5 w-2.5 rounded-full ${print.accent ? "bg-[var(--slash)]" : "bg-[rgba(255,255,255,.18)]"}`}
|
className={`h-2.5 w-2.5 rounded-full ${print.accent ? "bg-[var(--slash)]" : "bg-[rgba(255,255,255,.18)]"}`}
|
||||||
/>
|
/>
|
||||||
<span className="ml-2 truncate font-mono text-[10px] text-[rgba(244,242,237,.5)]">
|
<span className="ml-2 truncate font-mono text-[10px] text-[rgba(244,242,237,.5)]">
|
||||||
{new URL(print.url).hostname}
|
{print.url.replace("https://", "")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative h-[420px] overflow-hidden border border-[rgba(255,255,255,.12)] bg-black">
|
<div className="relative h-[min(50vh,380px)] overflow-hidden border border-[rgba(255,255,255,.12)] bg-black">
|
||||||
{/* o print real, deslizando de leve dentro da moldura (Ken Burns) */}
|
|
||||||
<img
|
<img
|
||||||
src={print.src}
|
src={print.src}
|
||||||
alt={`Captura do projeto ${print.name} em produção`}
|
alt={`Captura do projeto ${print.name} em produção`}
|
||||||
loading="lazy"
|
loading={i < 2 ? "eager" : "lazy"}
|
||||||
className="absolute inset-x-0 top-0 w-full origin-top scale-[1.04] object-cover object-top transition-transform duration-700 ease-out group-hover:-translate-y-6 group-hover:scale-100"
|
className="absolute inset-x-0 top-0 w-full origin-top scale-[1.03] object-cover object-top transition-transform duration-700 ease-out group-hover:-translate-y-5"
|
||||||
/>
|
/>
|
||||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,transparent_60%,rgba(10,10,10,.85)_100%)]" />
|
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,transparent_62%,rgba(10,10,10,.85)_100%)]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="border border-t-0 border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.03)] p-sp-5">
|
<div className="border border-t-0 border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.03)] p-5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h3 className="font-display text-[18px] font-extrabold tracking-[-0.02em] text-[var(--paper)]">
|
<h3 className="font-display text-[17px] font-extrabold tracking-[-0.02em] text-[var(--paper)]">
|
||||||
{print.name}
|
{print.name}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="font-mono text-[9px] uppercase tracking-[0.14em] text-[var(--slash)]">
|
<span className="font-mono text-[9px] uppercase tracking-[0.14em] text-[var(--slash)]">
|
||||||
ao vivo ↗
|
ao vivo ↗
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<DrawLine delay={0.1} />
|
<p className="mt-2 text-[12px] leading-6 text-[rgba(244,242,237,.62)]">
|
||||||
<p className="mt-sp-2 text-[13px] leading-6 text-[rgba(244,242,237,.66)]">
|
|
||||||
{print.desc}
|
{print.desc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</HorizontalScroll>
|
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</div>
|
||||||
|
|
||||||
|
{/* HUD */}
|
||||||
|
<div className="mx-auto mb-6 flex w-full max-w-[1240px] shrink-0 items-center gap-4 px-6 lg:px-16">
|
||||||
|
<span
|
||||||
|
data-strip-index
|
||||||
|
className="font-mono text-[10px] tracking-[0.14em] text-[var(--slash)]"
|
||||||
|
>
|
||||||
|
01
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] tracking-[0.14em] text-[rgba(244,242,237,.45)]">
|
||||||
|
/ 0{PRINTS.length}
|
||||||
|
</span>
|
||||||
|
<div className="h-px flex-1 bg-[rgba(255,255,255,.12)]">
|
||||||
|
<div
|
||||||
|
data-strip-bar
|
||||||
|
className="h-full w-full origin-left bg-[var(--slash)]"
|
||||||
|
style={{ transform: "scaleX(0)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -8,13 +8,19 @@ import { useEffect, useRef } from "react";
|
|||||||
* Contrato (declarativo, via atributos data-*):
|
* Contrato (declarativo, via atributos data-*):
|
||||||
* [data-reel="left|right|top|bottom|zoom|fade"]
|
* [data-reel="left|right|top|bottom|zoom|fade"]
|
||||||
* elemento entra em scrub contínuo: rolar p/ baixo avança, p/ cima REBOBINA.
|
* elemento entra em scrub contínuo: rolar p/ baixo avança, p/ cima REBOBINA.
|
||||||
* [data-reel-stagger="left|right|top|bottom|zoom|fade"]
|
* [data-reel-stagger="left|right|top|zoom|fade|bottom"]
|
||||||
* container: os FILHOS DIRETOS entram em cascata no mesmo trilho.
|
* container: os FILHOS DIRETOS entram em cascata no mesmo trilho.
|
||||||
* data-reel-start="top 88%" data-reel-end="top 26%" (opcional: janela do scrub)
|
* data-reel-start="top 88%" data-reel-end="top 26%" (opcional: janela do scrub)
|
||||||
* data-reel-distance="64" (opcional: px de deslocamento)
|
* data-reel-distance="64" (opcional: px de deslocamento)
|
||||||
*
|
*
|
||||||
* Nunca one-shot — tudo ligado ao progresso do scroll, como o reel do hero.
|
* Nunca one-shot — tudo ligado ao progresso do scroll, como o reel do hero.
|
||||||
* prefers-reduced-motion: nada é inicializado; conteúdo fica no fluxo natural.
|
* prefers-reduced-motion: nada é inicializado; conteúdo fica no fluxo natural.
|
||||||
|
*
|
||||||
|
* Anti-hidratação: elementos inicializados vivem num WeakSet (nunca um
|
||||||
|
* atributo data-reel-init no DOM) e o primeiro scan roda depois que a
|
||||||
|
* hidratação assentou (double-rAF + debounce) — MutationObserver dispara em
|
||||||
|
* microtask e pegaria nós ainda em hidratação, produzindo mismatch de
|
||||||
|
* atributos no overlay do Next.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type Direction = "left" | "right" | "top" | "bottom" | "zoom" | "fade";
|
type Direction = "left" | "right" | "top" | "bottom" | "zoom" | "fade";
|
||||||
@@ -49,6 +55,9 @@ export default function ReelDirector() {
|
|||||||
return undefined;
|
return undefined;
|
||||||
|
|
||||||
let teardown = () => {};
|
let teardown = () => {};
|
||||||
|
let raf1 = 0;
|
||||||
|
let raf2 = 0;
|
||||||
|
let timer = 0;
|
||||||
|
|
||||||
const boot = async () => {
|
const boot = async () => {
|
||||||
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
||||||
@@ -58,12 +67,16 @@ export default function ReelDirector() {
|
|||||||
if (!alive.current) return;
|
if (!alive.current) return;
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
|
const done = new WeakSet<Element>();
|
||||||
|
|
||||||
const ctx = gsap.context(() => {
|
const ctx = gsap.context(() => {
|
||||||
const scan = () => {
|
const scan = () => {
|
||||||
|
if (!alive.current) return;
|
||||||
for (const el of document.querySelectorAll<HTMLElement>(
|
for (const el of document.querySelectorAll<HTMLElement>(
|
||||||
"[data-reel]:not([data-reel-init])",
|
"[data-reel]",
|
||||||
)) {
|
)) {
|
||||||
el.dataset.reelInit = "1";
|
if (done.has(el)) continue;
|
||||||
|
done.add(el);
|
||||||
const dir = (el.dataset.reel || "bottom") as Direction;
|
const dir = (el.dataset.reel || "bottom") as Direction;
|
||||||
const dist = Number(el.dataset.reelDistance || DISTANCE);
|
const dist = Number(el.dataset.reelDistance || DISTANCE);
|
||||||
gsap.fromTo(el, fromVars(dir, dist), {
|
gsap.fromTo(el, fromVars(dir, dist), {
|
||||||
@@ -77,9 +90,10 @@ export default function ReelDirector() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const container of document.querySelectorAll<HTMLElement>(
|
for (const container of document.querySelectorAll<HTMLElement>(
|
||||||
"[data-reel-stagger]:not([data-reel-init])",
|
"[data-reel-stagger]",
|
||||||
)) {
|
)) {
|
||||||
container.dataset.reelInit = "1";
|
if (done.has(container)) continue;
|
||||||
|
done.add(container);
|
||||||
const dir = (container.dataset.reelStagger || "bottom") as Direction;
|
const dir = (container.dataset.reelStagger || "bottom") as Direction;
|
||||||
const dist = Number(container.dataset.reelDistance || DISTANCE);
|
const dist = Number(container.dataset.reelDistance || DISTANCE);
|
||||||
const children = Array.from(container.children) as HTMLElement[];
|
const children = Array.from(container.children) as HTMLElement[];
|
||||||
@@ -101,12 +115,25 @@ export default function ReelDirector() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
scan();
|
// debounce: nós de seções dinâmicas (next/dynamic) chegam depois
|
||||||
// seções chegam via next/dynamic depois do boot — observa nós novos
|
let pending = 0;
|
||||||
const observer = new MutationObserver(() => scan());
|
const scheduleScan = () => {
|
||||||
|
window.clearTimeout(pending);
|
||||||
|
pending = window.setTimeout(scan, 120);
|
||||||
|
};
|
||||||
|
|
||||||
|
// espera a hidratação assentar antes de tocar o DOM
|
||||||
|
raf1 = window.requestAnimationFrame(() => {
|
||||||
|
raf2 = window.requestAnimationFrame(() => {
|
||||||
|
timer = window.setTimeout(scan, 250);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const observer = new MutationObserver(scheduleScan);
|
||||||
observer.observe(document.body, { childList: true, subtree: true });
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
|
||||||
teardown = () => {
|
teardown = () => {
|
||||||
|
window.clearTimeout(pending);
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
ctx.revert();
|
ctx.revert();
|
||||||
};
|
};
|
||||||
@@ -116,6 +143,9 @@ export default function ReelDirector() {
|
|||||||
void boot();
|
void boot();
|
||||||
return () => {
|
return () => {
|
||||||
alive.current = false;
|
alive.current = false;
|
||||||
|
window.cancelAnimationFrame(raf1);
|
||||||
|
window.cancelAnimationFrame(raf2);
|
||||||
|
window.clearTimeout(timer);
|
||||||
teardown();
|
teardown();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
Reference in new issue
Block a user