Animate a React component with a configurable frame rate
Developing a web application in React and suddenly we want to add a fancy animation.
I was inspired to write this story with the following requirements:
- Animation is sensitive to frequency and speed of application updates.
- Animation is suitable for SVG elements.
- Animation is reusable and could be applied for any UI components.
- Animation is efficient and fast for all web browsers and platforms.
Let’s start
I will write code in Typescript and React. You can set up your own React+Typescript application by running one command:
npx create-react-app stopwatch --typescript
Create React component with SVG content that resembles a stopwatch.
import * as React from "react";
import * as ReactDOM from "react-dom";
function degreesToRadians(degrees: number): number {
return degrees / 180 * Math.PI - Math.PI / 2;
}const radius = 100;
const size = radius * 2;interface Props {
initialDegree: number;
}interface State {
degree: number;
}class StopWatch extends React.Component<Props, State> { public constructor(props: StopWatchProps) {
super(props);
this.state = {
degree: props.initialDegree
};
} public render() {
const radians = degreesToRadians(this.state.degree); // line begin at the circle center
const lineX1 = radius;
const lineY1 = radius; // Calculate line end from parametric expression for circle
const lineX2 = lineX1 + radius * Math.cos(radians);
const lineY2 = lineY1 + radius * Math.sin(radians); return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
>
<circle
cx={radius}
cy={radius}
r={radius}
fill="yellow"
/>
<line
x1={lineX1}
y1={lineY1}
x2={lineX2}
y2={lineY2}
strokeWidth="1"
stroke="red"
/>
</svg>
);
}
}ReactDOM.render(
<StopWatch initialDegree={0} />,
document.getElementById("root")
);
We get a static SVG image with an arrow at the start position, initialDegree
.
Set in motion
For the arrow animation we need two things:
- An update method which recalculates new angle for the arrow.
- Animation loop which triggers an update function.
Let’s add our update method and animation loop inside StopWatch
class.
public componentDidMount() {
this.update();
}private increment = 1;private update = () => {
this.setState(
(previous: State): State => {
return {
degree: (previous.degree + this.increment) % 360
};
},
);
window.requestAnimationFrame(this.update);
};
React componentDidMount
handler safely calls the update
method the first time that the component is mounted so it prevents warning: can't call setState on a component that is not yet mounted
.
On each update
call, we increment the state by 1 degree in the range from 0 to 259. At the last line, requestAnimationFrame
tells the browser to perform next animation repaint for the same update
method. As a rule, it happens about 60 times per second(~60FPS), but it depends on the browser and device performance.
As result, we get the arrow motion.
Configurable frame rate
We know that requestAnimationFrame
usually gives us ~60 FPS, so practically we could calculate component repaint from 1 to 60 times per second.
Add a frameRate
property to the component interface, and going forward we pass it as a component prop
.
interface Props {
initialDegree: number;
frameRate: number;
}
Add a text label with the current FPS
to the SVG element in the render
method.
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
>
<circle
cx={radius}
cy={radius}
r={radius}
fill="yellow"
/>
<line
x1={lineX1}
y1={lineY1}
x2={lineX2}
y2={lineY2}
strokeWidth="1"
stroke="red"
/>
<text x="70" y="50" fill="black">
{`FPS: ${this.props.frameRate}`}
</text>
</svg>
Now add base frame counter to the update
method for an estimation of how many frames we should wait till the next animation repaint.
private maxFPS = 60;
private frameCount = 0;private update = () => {
this.frameCount++; if (this.frameCount >= Math.round(
this.maxFPS / this.props.frameRate
)) {
this.setState(
(previous: State): State => {
return {
degree: (previous.degree + this.increment) % 360
};
},
); this.frameCount = 0;
} window.requestAnimationFrame(this.update);
}
Change increment
to 3, pass frameRate
property on StopWatch
initialisation, and add a few more examples.
private increment = 3;const App = () => (
<div style={{display: "flex"}}>
<StopWatch initialDegree={0} frameRate={60} />
<StopWatch initialDegree={0} frameRate={30} />
<StopWatch initialDegree={0} frameRate={20} />
</div>
);ReactDOM.render(
<App />,
document.getElementById("root")
);
If we want arrows to have the same rotation speed despite frame rate as in title image, we need to calculate increment
according to current FPS.
public constructor(props: Props) {
super(props);
this.state = {
degree: props.initialDegree
}; this.increment = this.maxFPS / props.frameRate;
}
Last step
To modularize our final result lets move animation functionality to separate file and make it reusable.
We will use the higher order component pattern for this purpose.
import * as React from "react";export type BaseProps = Readonly<{
frameRate: number;
}>;export type Options<Props extends BaseProps> = Readonly<{
update: (state: Props) => Props;
}>;export const MAX_FPS = 60;export const withAnimation = <Props extends BaseProps>(
options: Options<Props>
) => {
return(
Component: React.ComponentType<Props>
): React.ComponentClass<Props> => {
return class Animation extends React.Component<
Props, Props
> {
private frameCount = 0;
private frameId = 0; constructor(props: Props) {
super(props);
this.state = props;
} public render() {
return <Component {...this.state} />;
} public componentDidMount() {
this.update();
} public componentWillUnmount() {
if (this.frameId) {
window.cancelAnimationFrame(this.frameId);
}
} private update = () => {
this.frameCount++;
if (this.frameCount >= Math.round(
MAX_FPS / this.props.frameRate
)) {
this.setState(options.update);
this.frameCount = 0;
}
this.frameId =
window.requestAnimationFrame(this.update);
}; }
};
};
And now StopWatch
looks much simpler.
import * as React from "react";
import * as ReactDOM from "react-dom";
import { BaseProps, withAnimation } from "./reactFrameRate";function degreesToRadians(degree: number): number {
return degree / 180 * Math.PI - Math.PI / 2;
}const radius = 100;
const size = radius * 2;type Props = Readonly<{
degree: number;
}> & BaseProps;const StopWatch: React.SFC<Props> = props => { const radians = degreesToRadians(props.degree);
const lineX1 = radius;
const lineY1 = radius;
const lineX2 = lineX1 + radius * Math.cos(radians);
const lineY2 = lineY1 + radius * Math.sin(radians); return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
>
<circle
cx={radius}
cy={radius}
r={radius}
fill="yellow"
/>
<line
x1={lineX1}
y1={lineY1}
x2={lineX2}
y2={lineY2}
strokeWidth="1"
stroke="red"
/>
<text x="70" y="50" fill="black">
{`FPS: ${props.frameRate}`}
</text>
</svg>
);
};const options = {
update: (props: Props): Props => {
return {
...props,
degree: (props.degree + 180 / props.frameRate) % 360
};
}
};const WithAnimation = withAnimation(options)(StopWatch);const App = () => (
<div style={{display: "flex"}}>
<WithAnimation degree={0} frameRate={30} />
<WithAnimation degree={0} frameRate={10} />
<WithAnimation degree={0} frameRate={5} />
</div>
);ReactDOM.render(
<App />,
document.getElementById("root")
);
Final thoughts
There are still some ways to improve the code and performance improvement, but I hope this article would be useful for you and this approach finds a place in your React projects.
Some updates
If there is a need to stop animation at the certain moment. One of the option could be update isAnimating
flag from enclosed scope inside updateState
function. Will present it in React hooks style:
/* constants */
const frameRate = 60;
const initialDeg = 0;/* main application */
const App = () => {
/* store the animation toggle flag in state hook */
const [
isAnimating,
setAnimating,
] = React.useState<boolean>(true); const updateState = React.useCallback<
(state: Props) => Props
>((state: Props) => {
const newDeg = state.deg + 1;
/* stop animation when the angle approaches 270 degrees */
if (newDeg >= 270) {
setAnimating(false);
}
return {
...state,
deg: newDeg,
};
}, []);
const options = {
updateState,
frameRate,
};
const WithAnimation = React.useMemo(() => {
return withReactFrameRate<Props>(options)(Circle);
}, []);
return (
<WithAnimation deg={initialDeg} isAnimating={isAnimating} />
);
};