Animate a React component with a configurable frame rate

Leo
Source True
Published in
6 min readFeb 18, 2019
Captured from Github

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.

Captured from Github

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.

Captured from Github

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")
);
Captured from Github

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")
);
Captured from Github

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} />
);
};
Captured from Github

--

--

Leo
Source True

JavaScript/TypeScript developer. In past ActionScript 3.0. https://stesel.netlify.app/