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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user