CSS Animations as a DOM-Based Animations Framework

Chris Grounds
BBC Product & Technology
8 min readFeb 11, 2021

This is the second post in a series about how BBC Children’s and Education have migrated from native-first apps to interactive app experiences as Progressive Web Apps (PWA). As we saw in the first blog post, very soon we will be launching four CBeebies apps, completing the migration of old embedded tech to modern web standards and APIs on the new Universal App Platform (UAP). Part of this process has involved creating a new, declarative animations framework and re-thinking how we use them. This blog will be focused on that, but you can read about the strategy and motivation behind the creation of UAP here.

A large proportion of graphically intensive 2D web and app experiences use HTML Canvas or WebGL as their rendering target/API. This is true particularly because widely used rendering engines and frameworks, such as Pixi.js and Phaser.js, output to WebGL and fallback to Canvas if WebGL is not available.

The benefit of using such libraries is that they provide a JavaScript API that achieves pixel-perfect precision and very high performance. The disadvantages of such an approach are that since they are not DOM-based they are not declarative, making them harder to reason about, harder to maintain, and harder to make accessible. Moreover, since they target pixels they are harder to make responsive and are at a disadvantage compared to DOM-based technologies.

In contrast to this, CSS Animations allow us to work with the DOM and can be composited onto the GPU, meaning we can achieve good performance with maintainable code. Overall, moving to the DOM has allowed us to build components in a more commoditised way, with the trade-off being high fidelity experiences.

UAP is not specifically a web-app, rather it is first-and-foremost a platform. Consequently, when developing the animations library we had to keep certain requirements in mind in order to make the library a good fit for any end-user. These requirements are that the animations must be:

  • Reusable
  • Customisable and flexible
  • Easy and intuitive to use

The way we have met these conditions lies in writing a React Domain Specific Language (DSL) sitting on top of CSS animations, thereby providing compositionality, re-usability, flexibility to customise, and ease-of-use: plug ‘n play animations for any consumer.

The Animation Framework

CSS animations provide a declarative API enabling the developer to animate a DOM node and its children. The developer only needs to define certain properties about the animation (i.e. its duration, any delay, its timing function) and then define some keyframes that describe what the animation will do at certain points (i.e. 50% of the way through make the background-color red). However, it would not be maintainable to litter our children’s apps with animation keyframes. They would appear disparate, hard to find, and would not be re-usable.

The first step towards our DSL is to create an animations component inside of UAP. The job of this component is simply to expose a set of re-usable animations. Any keyframes and CSS animations are to be placed here, thereby solving the above problems. The next step is to use JSX and React to abstract CSS animations. This gives effortlessly us a compositional API, which is key to the way we have layered multiple animations upon each other. This simply comes from the fact that JSX is inherently a compositional language, meaning we can take multiple JSX components and combine them to form a new JSX component. This is precisely how we create multi-layered animations: a zoom animation is combined with a fade animation to create a composite zoom-fade JSX component. Animations can be layered infinitely this way to create varied and novel animations.

Zooming

Let’s consider an example to make this more concrete. Here is the API for an animation which zooms into an object.

<Zoom from={1} to={1.3} duration={2} delay={0}>
<RedBox>
</Zoom>

As you can see Zoom takes a set of props:

  • from-state (where it starts the animation)
  • to-state (where it ends it)
  • duration (how long it will take)
  • and a delay (how long the animation should wait before starting)

It’s important that the API we expose to our DSL is consistent and intuitive, and of course each component supports React Synthetic Events such as onAnimationEnd, giving the developer complete control over ordering and timing events.

Here is Zoom in action in Storybook:

Zoom In Animation

Anyone consuming UAP components/modules can pull in Zoom from the animation component and configure it through a few props, as easy as above, and then compose it with their animation target to achieve a similar effect.

Zooming internals

So that’s what the consumer of Zoom sees and interfaces with, but how did we actually achieve that API?

We use styled-components to create JSX components with tagged template literals and inline CSS. Styled components (or CSS in JSX more broadly) allows us to automatically scope styles, it removes the distinction between JSX and CSS which helps with re-usability, and provides an easy way to customise CSS through JS.

The syntax may look a little odd at first, but as you can see in the following snippet it is just CSS inside of a function/JSX component.

const zoomKeyframe = (from, to) => keyframes`
from {
transform: scale(${from || 1})
}
to {
transform: scale(${to || 1.3})
}
`;
export const Zoom = styled(AnimationLayer)`
will-change: transform;
${({ from = 1, to, delay = 0, duration, rotate }) =>
css`
animation: ${rotate
? zoomAndRotateKeyframe(from, to, rotate)
: zoomKeyframe(from, to)}
${duration}s ${cubicBezier} both;
animation-delay: ${delay}s;
`}
`;

Fade

Another example in use in the CBeebies Children’s apps are the Fade animations.

<FadeIn duration={2} delay={2}>
<RedBox />
</FadeIn>

and

<FadeOut duration={5} delay={1}>
<RedBox />
</FadeOut>

Here they are in action:

Fade In Animation
Fade Out Animation

It is fairly intuitive for the end-user to work out what props an animation component takes without looking at the documentation.

Compositional animations

Since these are just React components, we can compose them together to create chains of animations. For example, if we combine FadeIn with Zoom and FadeOut, we get an animation in which the red box will fade in, zoom, and then fade out.

<FadeIn duration={2} delay={0}>
<Zoom from={1} to={2} duration={2} delay={2}>
<FadeOut duration={2} delay={4}>
<RedBox />
</FadeOut>
</Zoom>
</FadeIn>

Like all good code, this code tells a story that is eminently easy to read — fade in, then zoom, then fade out. Timing is co-ordinated through the duration and delay props. We could have all animations occurring simultaneously by setting all the delays to be 0.

FadeIn, Zoom, FadeOut Animation

Keeping track of transition animations

Sometimes, keeping track of which animation to play can be difficult. Buttons can pop in when you initially launch the app, stay static for some time, bounce to remind you to tap on them, go back to the static state, so on. Using React props and state can quickly turn into a nested tree of if conditions that becomes very difficult to understand.

To solve this, we used a finite state machine library (xstate) to keep track of which states a component can be in, and the animations that link these states together. First, we define a state machine that lists the possible states, and assigns a different animation whenever a valid transition occurs.

const buttonsMachine = Machine(
{
id: "buttons",
initial: "hidden",
context: { animation: Invisible },
states: { /* ... states here ... */ },
},
{
actions: {
onShow: assign({ animation: (context) => PopIn }),
onHide: assign({ animation: (context) => PopOut }),
onUserIdle: assign({ animation: (context) => PopRemind }),
onUserActive: assign({ animation: (context) => NoAnimation }),
},
}
);

Then, we can use this state machine in combination with React props to decide which animation to play at a particular moment. If a React prop changes, then we attempt to transition into a different state, and if we succeed then the correct animation is applied.

const CarouselButtons = ({ show, isIdle }) => {
const [machine, send] = useMachine(buttonsMachine);
useEffect(() => {
send(show ? "SHOW" : "HIDE");
send(isIdle ? "USER_IDLE" : "USER_ACTIVE");
}, [show, isIdle]);
const Animation = machine.context.animation;

return (
<Animation>
<Buttons />
</Animation>
);
};

This allows us to easily support transition animations between many possible states without making the code hard to understand.

Animations in UAP CBeebies apps

We have a host of different animations, but they all follow the same general principles — and they are all compositional, re-usable, flexible, and easy to use.

Let’s finish by looking at the animations used in anger inside a real app. Any of these image assets can be swapped out, any of the animations can be changed in their many degrees of freedom or replaced outright, just by changing one line of code.

Playtime Island Intro Animation

And here is the code.

import {
Path,
PopIn,
BounceUp,
Zoom,
FadeOut,
} from "@uap/animations";
...const IntroAnimation = () => {... return (
<FadeOut
shouldFade={shouldFade}
onAnimationEnd={() => animationIsFinished && onFinish()}
>
<Zoom
onAnimationEnd={() => {
onFadeStart();
setAnimationIsFinished(true);
}}
onLoad={() => setBackgroundLoaded(true)}
delay={0}
duration={8}
to={1.3}
rotate={5}
>
<BackgroundImage {...backgroundImageProps} />
</Zoom>
{rightAnimationStarted && (
<BackPath>
<BackGlider {...backgliderProps} />
</BackPath>
)}
{foreGliderLoaded && (
<CenteredPopIn startingOpacity={0.2} duration={0.6} delay={0}>
<Logo {...logoProps} />
</CenteredPopIn>
)}
{logoLoaded && (
<LeftBounce
rotateLeft={true}
onAnimationIteration={() => appIsLoaded && setLeftPaused(true)}
>
{!leftPaused && (
<Bouncer {...bouncerProps} />
)}
</LeftBounce>
)}
{leftPopLoaded && (
<RightBounce
rotateRight={true}
onAnimationIteration={() => appIsLoaded && setRightPaused(true)}
onAnimationStart={() => setRightAnimationStarted(true)}
>
{!rightPaused && (
<Bouncer {...bouncerProps} />
)}
</RightBounce>
)}
{backgroundLoaded && (
<ForePath>
<ForeGlider {...foregliderProps} />
</ForePath>
)}
</FadeOut>
)
}

Hopefully, this has been an insightful blog about how we have created and put to use a declarative DSL to achieve composable and re-usable animations!

Stay tuned for more UAP-focused blog posts coming soon!

Universal App Platform Logo

--

--