perf(home): transicoes de Qualidade so com translate/opacity e titulos por palavra em CSS

Medido com trace do Chromium (rasterizacao no mesmo trecho de rolagem):
IA->Qualidade 840->558 ms, dentro de Qualidade 34->18 ms, Qualidade->Sobre
819->643 ms. Recorte animado, escala de camadas grandes e o desenho do diagrama
a cada quadro saem; o diagrama se desenha uma vez em CSS. TextDiffuseReveal
deixa de animar cada letra com desfoque.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Evandro Fonseca JuniorandClaude Opus 5.5 committed 2026-10-05 01:19:48 -03:00
1 parent 44efcfcb20
commit def1e22302
5 files changed
+121 -215

No files matched your search

@@ -1,81 +0,0 @@
import { render } from "@testing-library/react";
import type { ReactNode } from "react";
import { TextDiffuseReveal } from "./TextDiffuseReveal";
let mockReducedMotion = false;
let mockInView = true;
let mockAnimations: Array<{ animate?: Record<string, unknown> }> = [];
jest.mock("motion/react", () => {
const React = require("react");
return {
motion: {
span: ({
children,
initial: _initial,
animate,
transition: _transition,
onAnimationComplete: _onAnimationComplete,
...props
}: {
children?: ReactNode;
initial?: unknown;
animate?: Record<string, unknown>;
transition?: unknown;
onAnimationComplete?: () => void;
} & Record<string, unknown>) => {
mockAnimations.push({ animate });
return React.createElement("span", props, children);
},
},
useInView: () => mockInView,
useReducedMotion: () => mockReducedMotion,
};
});
describe("TextDiffuseReveal reduced motion", () => {
beforeEach(() => {
mockReducedMotion = false;
mockInView = true;
mockAnimations = [];
});
it("clears blur and scale when reduced motion resolves after mount", () => {
const { rerender } = render(<TextDiffuseReveal text="Reveal" />);
mockAnimations = [];
mockReducedMotion = true;
rerender(<TextDiffuseReveal text="Reveal" />);
expect(mockAnimations).toHaveLength(6);
expect(mockAnimations).toEqual(
expect.arrayContaining(
Array.from({ length: 6 }, () =>
expect.objectContaining({
animate: {
opacity: 1,
filter: "blur(0px)",
scale: 1,
},
}),
),
),
);
});
it("keeps the first render independent of the reduced-motion preference", () => {
mockReducedMotion = true;
mockInView = false;
render(<TextDiffuseReveal text="Reveal" />);
expect(mockAnimations[0]).toEqual({
animate: {
filter: "blur(12px)",
opacity: 0,
scale: 0.9,
},
});
});
});
@@ -0,0 +1,30 @@
import { render, screen } from "@testing-library/react";
import { TextDiffuseReveal } from "./TextDiffuseReveal";
let mockInView = false;
jest.mock("motion/react", () => ({ useInView: () => mockInView }));
describe("TextDiffuseReveal", () => {
beforeEach(() => {
mockInView = false;
});
it("keeps the whole sentence readable once for assistive technology", () => {
const { container } = render(<TextDiffuseReveal text="Software feito a partir do seu negócio" />);
expect(screen.getByText("Software feito a partir do seu negócio")).toHaveClass("sr-only");
const words = container.querySelectorAll(".text-reveal-word");
expect(words).toHaveLength(7);
expect(words[0].closest("[aria-hidden='true']")).not.toBeNull();
});
it("marks the title as shown only after it enters the viewport", () => {
const { container, rerender } = render(<TextDiffuseReveal text="Reveal me" />);
expect(container.querySelector(".text-reveal")).not.toHaveAttribute("data-shown");
mockInView = true;
rerender(<TextDiffuseReveal text="Reveal me" />);
expect(container.querySelector(".text-reveal")).toHaveAttribute("data-shown");
});
});
+25 -102
View File
@@ -1,8 +1,7 @@
"use client"; "use client";
import { motion, useInView } from "motion/react"; import { useInView } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react"; import { type CSSProperties, useRef } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
interface TextDiffuseRevealProps { interface TextDiffuseRevealProps {
text: string; text: string;
@@ -12,8 +11,12 @@ interface TextDiffuseRevealProps {
amount?: number; amount?: number;
} }
const EXPO_OUT = [0.16, 1, 0.3, 1] as const; /**
* Título que entra palavra por palavra. A animação é CSS (.text-reveal em globals.css) com transparência
* e deslocamento: a placa de vídeo compõe sem redesenhar. A versão anterior animava cada letra com
* desfoque e renderizava o React de novo a cada letra, o que pesava justamente durante as transições.
* Movimento reduzido: o CSS mostra o texto direto, sem animação.
*/
export function TextDiffuseReveal({ export function TextDiffuseReveal({
text, text,
className = "", className = "",
@@ -22,111 +25,31 @@ export function TextDiffuseReveal({
amount = 0.3, amount = 0.3,
}: Readonly<TextDiffuseRevealProps>) { }: Readonly<TextDiffuseRevealProps>) {
const ref = useRef<HTMLSpanElement | null>(null); const ref = useRef<HTMLSpanElement | null>(null);
const shouldReduceMotion = useReducedMotion();
const isInView = useInView(ref, { once, amount }); const isInView = useInView(ref, { once, amount });
const [staggerDelay, setStaggerDelay] = useState(0.03); let word = 0;
const [completedChars, setCompletedChars] = useState(0);
useEffect(() => {
const media = window.matchMedia("(max-width: 768px)");
const updateStagger = () => setStaggerDelay(media.matches ? 0.015 : 0.03);
updateStagger();
media.addEventListener("change", updateStagger);
return () => media.removeEventListener("change", updateStagger);
}, []);
const tokens = useMemo(() => {
let offset = 0;
return text
.split(/(\n|\s+)/)
.filter(Boolean)
.map((value) => {
const key = `${offset}:${value}`;
offset += value.length;
return { key, value };
});
}, [text]);
const animatedChars = useMemo(
() =>
Array.from(text).filter((char) => char !== "\n" && char !== " ").length,
[text],
);
useEffect(() => {
if (!isInView) {
setCompletedChars(0);
}
}, [isInView]);
const hasActiveWillChange =
!shouldReduceMotion && isInView && completedChars < animatedChars;
let animatedIndex = 0;
return ( return (
<span <span
ref={ref} ref={ref}
className={className} className={`text-reveal ${className}`.trim()}
style={{ data-shown={isInView ? "" : undefined}
willChange: hasActiveWillChange style={{ "--d": `${startDelay}s` } as CSSProperties}
? "filter, opacity, transform"
: undefined,
}}
> >
<span className="sr-only">{text}</span> <span className="sr-only">{text}</span>
<span aria-hidden="true"> <span aria-hidden="true">
{tokens.map(({ key, value: token }) => { {text
if (token === "\n") { .split(/(\n|\s+)/)
return <br key={key} />; .filter(Boolean)
} .map((token, index) => {
const key = `${index}:${token}`;
if (/^\s+$/.test(token)) { if (token === "\n") return <br key={key} />;
return <span key={key}>{token.replace(/ /g, "\u00A0")}</span>; if (/^\s+$/.test(token)) return " ";
} return (
<span key={key} className="text-reveal-word" style={{ "--i": word++ } as CSSProperties}>
return ( {token}
<span key={key} className="inline-block whitespace-nowrap"> </span>
{Array.from(token).map((char) => { );
const currentIndex = animatedIndex; })}
animatedIndex += 1;
return (
<motion.span
key={`${key}-${currentIndex}-${char}`}
className="inline-block"
initial={
shouldReduceMotion
? { opacity: 0 }
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
}
animate={
isInView
? { filter: "blur(0px)", opacity: 1, scale: 1 }
: shouldReduceMotion
? { filter: "blur(0px)", opacity: 0, scale: 1 }
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
}
transition={{
duration: shouldReduceMotion ? 0.3 : 0.6,
ease: EXPO_OUT,
delay: startDelay + currentIndex * staggerDelay,
}}
onAnimationComplete={() => {
if (!shouldReduceMotion) {
setCompletedChars((count) =>
count < animatedChars ? count + 1 : count,
);
}
}}
>
{char}
</motion.span>
);
})}
</span>
);
})}
</span> </span>
</span> </span>
); );
+13 -32
View File
@@ -85,48 +85,27 @@ const TRANSITIONS: Transition[] = [
0, 0,
); );
}, },
// IA → qualidade: a tela se abre do centro como duas portas, e o diagrama se desenha. // IA → qualidade: a seção sobe mais rápido que a rolagem, como um cartão, sobre a anterior que escurece.
// Só 2D: com rotação 3D o Chrome adiava a pintura do diagrama (camada grande demais). // Só translate/opacity: recorte animado, escala (o Chrome rasteriza de novo a cada novo tamanho) e o
// desenho do diagrama a cada quadro dobravam o custo de pintura desta passagem. O diagrama se desenha
// uma vez, em CSS, quando a passagem começa (classe is-drawing, ver onEnter).
(tl, prev, next) => { (tl, prev, next) => {
holdPrev(tl, prev); holdPrev(tl, prev);
holdNext(tl, next); tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0).fromTo(
tl.fromTo(
prev,
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
{ scale: 0.9, opacity: 0.25 },
0,
).fromTo(
next, next,
{ clipPath: () => `inset(0px 50% ${next.offsetHeight - vh()}px 50%)` }, { yPercent: 0, y: () => vh() * 0.35 },
{ clipPath: () => `inset(0px 0% ${next.offsetHeight - vh()}px 0%)`, ease: "power2.inOut" }, { y: 0, ease: "power2.out" },
0, 0,
); );
const lines = next.querySelectorAll(
".quality-dimensions path, .quality-browser-frame, .quality-blueprint > path.quality-wire, .quality-blueprint > rect.quality-wire, .quality-blueprint > g.quality-wire > *, .quality-browser-button",
);
const labels = next.querySelectorAll(
".quality-blueprint text, .quality-browser-dots, .quality-visual-label, .quality-visual figcaption",
);
if (lines.length) {
tl.set(lines, { attr: { pathLength: 1 }, strokeDasharray: 1 }, 0).fromTo(
lines,
{ strokeDashoffset: 1 },
{ strokeDashoffset: 0, duration: 0.4, stagger: { amount: 0.25 } },
0.3,
);
}
if (labels.length) {
tl.fromTo(labels, { opacity: 0 }, { opacity: 1, duration: 0.25, stagger: { amount: 0.2 } }, 0.5);
}
}, },
// qualidade → sobre: cortina diagonal // qualidade → sobre: a página entra girando pela esquerda, como uma folha virando (só transform)
(tl, prev, next) => { (tl, prev, next) => {
holdPrev(tl, prev); holdPrev(tl, prev);
holdNext(tl, next); holdNext(tl, next);
tl.fromTo(prev, { opacity: 1, xPercent: 0 }, { opacity: 0.25, xPercent: 8 }, 0).fromTo( tl.fromTo(prev, { opacity: 1, xPercent: 0 }, { opacity: 0.25, xPercent: 20 }, 0).fromTo(
next, next,
{ clipPath: () => `polygon(0% 0px, 0% 0px, -30% ${vh()}px, -30% ${vh()}px)` }, { xPercent: -100, rotation: -7, transformOrigin: "0% 0%" },
{ clipPath: () => `polygon(0% 0px, 130% 0px, 100% ${vh()}px, -30% ${vh()}px)` }, { xPercent: 0, rotation: 0, ease: "power2.out" },
0, 0,
); );
}, },
@@ -199,6 +178,8 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
invalidateOnRefresh: true, invalidateOnRefresh: true,
// passagem concluída: o recorte já cobre a tela inteira; sem ele a seção pinta normalmente // passagem concluída: o recorte já cobre a tela inteira; sem ele a seção pinta normalmente
onLeave: () => gsap.set(next, { clipPath: "none" }), onLeave: () => gsap.set(next, { clipPath: "none" }),
// diagrama de qualidade: desenha uma vez quando a seção começa a entrar
onEnter: () => next.classList.add("is-drawing"),
}, },
}); });
transition(tl, prev, next); transition(tl, prev, next);
+53
View File
@@ -1481,6 +1481,39 @@ html.lenis {
} }
} }
/* Entrada do diagrama (classe is-drawing posta por useCinematicScroll): linhas se desenham e rótulos
aparecem uma única vez, sem acompanhar cada quadro da rolagem */
@media (prefers-reduced-motion: no-preference) {
/* só com as transições ativas (html.lenis): sem o script, o diagrama aparece normalmente */
html.lenis .quality-lab:not(.is-drawing) :is(.quality-blueprint, .quality-visual-label, .quality-visual figcaption) {
opacity: 0;
}
.quality-lab.is-drawing .quality-blueprint :is(.quality-dimensions path, .quality-browser-frame, .quality-wire, .quality-browser-button) {
stroke-dasharray: 1800; /* maior que o maior contorno (moldura: ~1600) */
animation: quality-draw 1.1s var(--ease-out) both;
}
.quality-lab.is-drawing :is(.quality-blueprint, .quality-visual-label, .quality-visual figcaption) {
animation: quality-fade 0.6s var(--ease-out) both;
}
}
@keyframes quality-draw {
from {
stroke-dashoffset: 1800;
}
to {
stroke-dashoffset: 0;
}
}
@keyframes quality-fade {
from {
opacity: 0;
}
}
@keyframes quality-trace { @keyframes quality-trace {
from { from {
stroke-dashoffset: 1; stroke-dashoffset: 1;
@@ -1786,3 +1819,23 @@ html.lenis {
animation: none; animation: none;
} }
} }
/* Títulos de seção (TextDiffuseReveal): entram palavra por palavra só com transparência e deslocamento,
que a placa de vídeo compõe sem redesenhar */
.text-reveal-word {
display: inline-block;
}
@media (prefers-reduced-motion: no-preference) {
.text-reveal-word {
transition:
opacity 0.7s var(--ease-out),
transform 0.9s var(--ease-out);
transition-delay: calc(var(--d, 0s) + var(--i, 0) * 55ms);
}
.text-reveal:not([data-shown]) .text-reveal-word {
opacity: 0;
transform: translateY(0.35em);
}
}