I want to use this jumping text effect (https://web.dev/patterns/animation/animated-words/) in my app and while I am creating the component in React I got into some problems. I created a styled component and put the css into it. Then I use the innerHTML for DOM manipulation in my React component and finally when I test no effect here. What did I wrong?
App.tsx
import styled from "styled-components";
const Jumper = styled.p`
@keyframes trampoline {
0% {
transform: translateY(100%);
animation-timing-function: ease-out;
}
50% {
transform: translateY(0);
animation-timing-function: ease-in;
}
}
@media (prefers-reduced-motion: no-preference) {
[word-animation] {
display: inline-flex;
flex-wrap: wrap;
gap: 1ch;
}
[word-animation="trampoline"] > span {
display: inline-block;
transform: translateY(100%);
animation: trampoline 3s ease calc(var(--index) * 150 * 1ms) infinite
alternate;
}
}
`;
export default function App() {
const span = (text, index) => {
const node = document.createElement("span");
node.textContent = text;
node.style.setProperty("--index", index);
return node;
};
const byWord = (text) => text.split(" ").map(span);
const { matches: motionOK } = window.matchMedia(
"(prefers-reduced-motion: no-preference)"
);
if (motionOK) {
const splitTargets = document.querySelectorAll("[split-by]");
splitTargets.forEach((node) => {
let nodes = byWord(node.innerText);
if (nodes) node.firstChild.replaceWith(...nodes);
});
}
return <Jumper>split a paragraph of content 🤘💀</Jumper>;
}
As suggested by @AnTony in the question comment, the idea here is to convert the DOM query and manipulation logic into React (styled) components.
const splitTargets = document.querySelectorAll("[split-by]");
...is to query the DOM contents to be animated. We can just define a custom React component for that. We can use your <Jumper>
component for example.
const node = document.createElement("span");
node.textContent = text;
...is to create DOM nodes. We can just use JSX syntax for that!
node.style.setProperty("--index", index);
...here it uses a CSS variable, so that it can affect the CSS rule about animation delay. It could also have just defined the exact style with the value and applied it on the created Element. That is what we can achieve with a styled component and adapting its style based on props.
node.firstChild.replaceWith(...nodes);
...is to replace the DOM content, because that content pre-exists and it tries to replace it by animation-powered Elements. In our case we can just directly use custom React components, so there is "nothing to replace", we directly use our animated components.
In the end:
/**
* Own styled Component for each "span" with animation
*/
const StyledWordTrampoline = styled.span<{ index: number }>`
@media (prefers-reduced-motion: no-preference) {
display: inline-block;
transform: translateY(100%);
animation: trampoline 3s ease calc(${({ index }) => index} * 150 * 1ms)
infinite alternate;
}
`;
/**
* Generate a styled component instead of a <span>
*/
const spanTrampoline = (text: string, index: number) => (
<StyledWordTrampoline index={index}>{text}</StyledWordTrampoline>
);
function byWordTrampoline(text: string) {
return text?.split(" ").map(spanTrampoline);
}
/**
* Pass the content and desired animation type as props
*/
function Jumper({
content,
wordAnimation
}: {
content: string; // Maybe could be reworked to accept ReactNode?
wordAnimation: "trampoline"; // More values could be implemented
}) {
const words = useMemo(() => {
const { matches: motionOK } = window.matchMedia(
"(prefers-reduced-motion: no-preference)"
);
if (motionOK) {
switch (wordAnimation) {
case "trampoline":
return addSeparator(byWordTrampoline(content));
}
}
return [];
}, [content, wordAnimation]);
return (
<>
{words.map((word, index) => (
// Just to get rid of React warning about key prop,
// no real identifer available, so fallback to index...
<Fragment key={index}>{word}</Fragment>
))}
</>
);
}
const StyledJumper = styled(Jumper)`
@keyframes trampoline {
0% {
transform: translateY(100%);
animation-timing-function: ease-out;
}
50% {
transform: translateY(0);
animation-timing-function: ease-in;
}
}
@media (prefers-reduced-motion: no-preference) {
display: inline-flex;
flex-wrap: wrap;
gap: 1ch;
}
`;
export default function App() {
// No longer perform the logic within the App,
// delegate to the custom React component
return (
<StyledJumper
content="split a paragraph of content 🤘💀"
wordAnimation="trampoline"
/>
);
}
Demo on CodeSandbox: https://codesandbox.io/s/practical-sara-4rc6wr?file=/src/App.tsx