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
+285
-74
No files matched your search
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { DrawLine } from "../animations/DrawLine";
|
||||
import HorizontalScroll from "../cinematic/HorizontalScroll";
|
||||
import { Section } from "../Section";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Palco extra estilo product-page: filmstrip horizontal PINNADO com prints
|
||||
* reais capturados dos deployments em produção (public/assets/prints).
|
||||
* O scroll vertical empurra a faixa na horizontal (scrub), cada card entra
|
||||
* com rotateY/scale ligados ao mesmo trilho — sensação de carrossel de vídeo.
|
||||
* Palco 2 do estilo product-page: filmstrip horizontal dirigido pelo scroll.
|
||||
* Arquitetura: runway 280vh + palco `position: sticky` (mesma técnica provada
|
||||
* no reel do hero — sem GSAP pin, sem wrapper de flex). O scrub move a faixa
|
||||
* 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 = [
|
||||
@@ -42,68 +43,248 @@ const PRINTS = [
|
||||
},
|
||||
];
|
||||
|
||||
const CARD_W = "min(80vw, 430px)";
|
||||
|
||||
export default function PrintReel() {
|
||||
return (
|
||||
<Section
|
||||
id="print-reel"
|
||||
eyebrow="No ar, rodando"
|
||||
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">
|
||||
<HorizontalScroll className="w-full">
|
||||
{PRINTS.map((print) => (
|
||||
<a
|
||||
key={print.name}
|
||||
data-scroll-item
|
||||
href={print.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex w-[min(78vw,380px)] shrink-0 flex-col"
|
||||
>
|
||||
{/* 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="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)]"}`}
|
||||
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 (
|
||||
<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)]">
|
||||
{new URL(print.url).hostname}
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
<div className="relative h-[420px] overflow-hidden border border-[rgba(255,255,255,.12)] bg-black">
|
||||
{/* o print real, deslizando de leve dentro da moldura (Ken Burns) */}
|
||||
<img
|
||||
src={print.src}
|
||||
alt={`Captura do projeto ${print.name} em produção`}
|
||||
loading="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"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,transparent_60%,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-sp-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-display text-[18px] 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>
|
||||
<DrawLine delay={0.1} />
|
||||
<p className="mt-sp-2 text-[13px] leading-6 text-[rgba(244,242,237,.66)]">
|
||||
{print.desc}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</HorizontalScroll>
|
||||
<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 }}
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
</Section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -8,13 +8,19 @@ import { useEffect, useRef } from "react";
|
||||
* Contrato (declarativo, via atributos data-*):
|
||||
* [data-reel="left|right|top|bottom|zoom|fade"]
|
||||
* 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.
|
||||
* data-reel-start="top 88%" data-reel-end="top 26%" (opcional: janela do scrub)
|
||||
* data-reel-distance="64" (opcional: px de deslocamento)
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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";
|
||||
@@ -49,6 +55,9 @@ export default function ReelDirector() {
|
||||
return undefined;
|
||||
|
||||
let teardown = () => {};
|
||||
let raf1 = 0;
|
||||
let raf2 = 0;
|
||||
let timer = 0;
|
||||
|
||||
const boot = async () => {
|
||||
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
||||
@@ -58,12 +67,16 @@ export default function ReelDirector() {
|
||||
if (!alive.current) return;
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const done = new WeakSet<Element>();
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const scan = () => {
|
||||
if (!alive.current) return;
|
||||
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 dist = Number(el.dataset.reelDistance || DISTANCE);
|
||||
gsap.fromTo(el, fromVars(dir, dist), {
|
||||
@@ -77,9 +90,10 @@ export default function ReelDirector() {
|
||||
});
|
||||
}
|
||||
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 dist = Number(container.dataset.reelDistance || DISTANCE);
|
||||
const children = Array.from(container.children) as HTMLElement[];
|
||||
@@ -101,12 +115,25 @@ export default function ReelDirector() {
|
||||
}
|
||||
};
|
||||
|
||||
scan();
|
||||
// seções chegam via next/dynamic depois do boot — observa nós novos
|
||||
const observer = new MutationObserver(() => scan());
|
||||
// debounce: nós de seções dinâmicas (next/dynamic) chegam depois
|
||||
let pending = 0;
|
||||
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 });
|
||||
|
||||
teardown = () => {
|
||||
window.clearTimeout(pending);
|
||||
observer.disconnect();
|
||||
ctx.revert();
|
||||
};
|
||||
@@ -116,6 +143,9 @@ export default function ReelDirector() {
|
||||
void boot();
|
||||
return () => {
|
||||
alive.current = false;
|
||||
window.cancelAnimationFrame(raf1);
|
||||
window.cancelAnimationFrame(raf2);
|
||||
window.clearTimeout(timer);
|
||||
teardown();
|
||||
};
|
||||
}, []);
|
||||
|
||||
Reference in new issue
Block a user