fix(story): filmstrip de prints com sticky+scrub dedado e ReelDirector pos-hidratacao sem mutation no DOM

This commit is contained in:
Evandro Fonseca Junior committed 2026-08-30 21:04:11 -03:00
1 parent ecadb7c540
commit a3d080d4d0
2 files changed
+285 -74

No files matched your search

+247 -66
View File
@@ -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,68 +43,248 @@ const PRINTS = [
}, },
]; ];
const CARD_W = "min(80vw, 430px)";
export default function PrintReel() { export default function PrintReel() {
return ( const sectionRef = useRef<HTMLElement>(null);
<Section const [engine, setEngine] = useState(false);
id="print-reel"
eyebrow="No ar, rodando" useEffect(() => {
title="Prints ao vivo dos sistemas em produção" if (window.matchMedia("(prefers-reduced-motion: reduce)").matches)
description="Capturas reais dos deployments — role e a faixa corre na horizontal como um reel." return undefined;
dark setEngine(true);
disableChildrenAnimation return undefined;
> }, []);
<div className="flex h-[100dvh] items-center">
<HorizontalScroll className="w-full"> useEffect(() => {
{PRINTS.map((print) => ( if (!engine) return undefined;
<a const section = sectionRef.current;
key={print.name} if (!section) return undefined;
data-scroll-item
href={print.url} let cleanup = () => {};
target="_blank" let cancelled = false;
rel="noopener noreferrer"
className="group flex w-[min(78vw,380px)] shrink-0 flex-col" const setup = async () => {
> const [{ gsap }, { ScrollTrigger }] = await Promise.all([
{/* moldura de navegador */} import("gsap"),
<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"> import("gsap/ScrollTrigger"),
<div className="flex items-center gap-2"> ]);
<span className="h-2.5 w-2.5 rounded-full bg-[rgba(255,255,255,.18)]" /> if (cancelled) return;
<span className="h-2.5 w-2.5 rounded-full bg-[rgba(255,255,255,.18)]" /> gsap.registerPlugin(ScrollTrigger);
<span
className={`h-2.5 w-2.5 rounded-full ${print.accent ? "bg-[var(--slash)]" : "bg-[rgba(255,255,255,.18)]"}`} 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 (
<section
id="print-reel"
className="border-t border-[rgba(255,255,255,.06)] bg-[var(--ink)] py-16 text-[var(--paper)]"
>
<div className="mx-auto w-full max-w-[1240px] px-6">
<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) => (
<a
key={print.name}
href={print.url}
target="_blank"
rel="noopener noreferrer"
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"
/> />
<span className="ml-2 truncate font-mono text-[10px] text-[rgba(244,242,237,.5)]"> <div className="p-5">
{new URL(print.url).hostname} <div className="font-display text-[18px] font-extrabold">
</span> {print.name}
</div> </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> </div>
<div className="relative h-[420px] overflow-hidden border border-[rgba(255,255,255,.12)] bg-black"> <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)]">
{/* o print real, deslizando de leve dentro da moldura (Ken Burns) */} Prints ao vivo da produção
<img </h2>
src={print.src} </div>
alt={`Captura do projeto ${print.name} em produção`} <div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.5)]">
loading="lazy" ← role para percorrer →
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" </div>
/> </div>
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,transparent_60%,rgba(10,10,10,.85)_100%)]" />
</div> {/* faixa */}
<div className="border border-t-0 border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.03)] p-sp-5"> <div className="flex min-h-0 flex-1 items-center">
<div className="flex items-center justify-between"> <div
<h3 className="font-display text-[18px] font-extrabold tracking-[-0.02em] text-[var(--paper)]"> data-strip-track
{print.name} 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"
</h3> >
<span className="font-mono text-[9px] uppercase tracking-[0.14em] text-[var(--slash)]"> {PRINTS.map((print, i) => (
ao vivo ↗ <a
</span> key={print.name}
</div> data-strip-card
<DrawLine delay={0.1} /> href={print.url}
<p className="mt-sp-2 text-[13px] leading-6 text-[rgba(244,242,237,.66)]"> target="_blank"
{print.desc} rel="noopener noreferrer"
</p> className="group flex shrink-0 flex-col will-change-transform"
</div> style={{ width: CARD_W }}
</a> >
))} <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">
</HorizontalScroll> <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)]" />
<span
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)]">
{print.url.replace("https://", "")}
</span>
</div>
</div>
<div className="relative h-[min(50vh,380px)] overflow-hidden border border-[rgba(255,255,255,.12)] bg-black">
<img
src={print.src}
alt={`Captura do projeto ${print.name} em produção`}
loading={i < 2 ? "eager" : "lazy"}
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_62%,rgba(10,10,10,.85)_100%)]" />
</div>
<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 gap-3">
<h3 className="font-display text-[17px] font-extrabold tracking-[-0.02em] text-[var(--paper)]">
{print.name}
</h3>
<span className="font-mono text-[9px] uppercase tracking-[0.14em] text-[var(--slash)]">
ao vivo ↗
</span>
</div>
<p className="mt-2 text-[12px] leading-6 text-[rgba(244,242,237,.62)]">
{print.desc}
</p>
</div>
</a>
))}
</div>
</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> </div>
</Section> </section>
); );
} }
+38 -8
View File
@@ -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();
}; };
}, []); }, []);