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:
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");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useInView } from "motion/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { useInView } from "motion/react";
|
||||
import { type CSSProperties, useRef } from "react";
|
||||
|
||||
interface TextDiffuseRevealProps {
|
||||
text: string;
|
||||
@@ -12,8 +11,12 @@ interface TextDiffuseRevealProps {
|
||||
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({
|
||||
text,
|
||||
className = "",
|
||||
@@ -22,111 +25,31 @@ export function TextDiffuseReveal({
|
||||
amount = 0.3,
|
||||
}: Readonly<TextDiffuseRevealProps>) {
|
||||
const ref = useRef<HTMLSpanElement | null>(null);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const isInView = useInView(ref, { once, amount });
|
||||
const [staggerDelay, setStaggerDelay] = useState(0.03);
|
||||
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;
|
||||
let word = 0;
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
style={{
|
||||
willChange: hasActiveWillChange
|
||||
? "filter, opacity, transform"
|
||||
: undefined,
|
||||
}}
|
||||
className={`text-reveal ${className}`.trim()}
|
||||
data-shown={isInView ? "" : undefined}
|
||||
style={{ "--d": `${startDelay}s` } as CSSProperties}
|
||||
>
|
||||
<span className="sr-only">{text}</span>
|
||||
<span aria-hidden="true">
|
||||
{tokens.map(({ key, value: token }) => {
|
||||
if (token === "\n") {
|
||||
return <br key={key} />;
|
||||
}
|
||||
|
||||
if (/^\s+$/.test(token)) {
|
||||
return <span key={key}>{token.replace(/ /g, "\u00A0")}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={key} className="inline-block whitespace-nowrap">
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
{text
|
||||
.split(/(\n|\s+)/)
|
||||
.filter(Boolean)
|
||||
.map((token, index) => {
|
||||
const key = `${index}:${token}`;
|
||||
if (token === "\n") return <br key={key} />;
|
||||
if (/^\s+$/.test(token)) return " ";
|
||||
return (
|
||||
<span key={key} className="text-reveal-word" style={{ "--i": word++ } as CSSProperties}>
|
||||
{token}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -85,48 +85,27 @@ const TRANSITIONS: Transition[] = [
|
||||
0,
|
||||
);
|
||||
},
|
||||
// IA → qualidade: a tela se abre do centro como duas portas, e o diagrama se desenha.
|
||||
// Só 2D: com rotação 3D o Chrome adiava a pintura do diagrama (camada grande demais).
|
||||
// IA → qualidade: a seção sobe mais rápido que a rolagem, como um cartão, sobre a anterior que escurece.
|
||||
// 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) => {
|
||||
holdPrev(tl, prev);
|
||||
holdNext(tl, next);
|
||||
tl.fromTo(
|
||||
prev,
|
||||
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
|
||||
{ scale: 0.9, opacity: 0.25 },
|
||||
0,
|
||||
).fromTo(
|
||||
tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0).fromTo(
|
||||
next,
|
||||
{ clipPath: () => `inset(0px 50% ${next.offsetHeight - vh()}px 50%)` },
|
||||
{ clipPath: () => `inset(0px 0% ${next.offsetHeight - vh()}px 0%)`, ease: "power2.inOut" },
|
||||
{ yPercent: 0, y: () => vh() * 0.35 },
|
||||
{ y: 0, ease: "power2.out" },
|
||||
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) => {
|
||||
holdPrev(tl, prev);
|
||||
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,
|
||||
{ clipPath: () => `polygon(0% 0px, 0% 0px, -30% ${vh()}px, -30% ${vh()}px)` },
|
||||
{ clipPath: () => `polygon(0% 0px, 130% 0px, 100% ${vh()}px, -30% ${vh()}px)` },
|
||||
{ xPercent: -100, rotation: -7, transformOrigin: "0% 0%" },
|
||||
{ xPercent: 0, rotation: 0, ease: "power2.out" },
|
||||
0,
|
||||
);
|
||||
},
|
||||
@@ -199,6 +178,8 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
||||
invalidateOnRefresh: true,
|
||||
// passagem concluída: o recorte já cobre a tela inteira; sem ele a seção pinta normalmente
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
from {
|
||||
stroke-dashoffset: 1;
|
||||
@@ -1786,3 +1819,23 @@ html.lenis {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user