Blog

Keyframe animations in CSS Modules

While trying to reuse keyframe animations across React components, I learned a lot about scoping in CSS Modules.

In design systems, I like having generic @keyframes animations like rotateClockwise or fadeIn for reuse in various components. With plain CSS there’s no scoping, so when that CSS loads the animation works.

In a Magento PWA Studio project, scoping is enforced through CSS Modules and Webpack CSS loader. I assumed that only component classes were scoped, but keyframe rules are too. That makes defining reusable animations difficult.

Here’s how I worked through it.

CSS animations in CSS + Sass

From 2012–2019 all my CSS was preprocessed from Sass. A common convention is importing design tokens (animations + functions + variables + mixins) before the tangible selectors (resets + components) that utilize them.

GravDept's open-source Frontend Starter repo shows how to import Sass partials, and the CSS Imports Field Manual explains why.

Here’s a simplified example:

// css/app.scss

// Animation
@import 'core/animation/rotateClockwise';

// Component
@import 'component/spinner';
// css/core/animation/_rotateClockwise.scss

@keyframes rotateClockwise {
    0%   { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
}
// css/component/_spinner.scss

.spinner {
    animation-duration: 1s;
    animation-iteration-count: infinite;
    animation-name: rotateClockwise;
    animation-timing-function: linear;
}

Nothing special. It’s nearly plain CSS.

CSS animations in CSS Modules

Magento’s PWA Studio uses build tooling like Webpack and CSS Modules, which enables:

  • Code splitting — A React component’s CSS only loads when it mounts, which improves performance.
  • Scoping — Class names in HTML + CSS are always rewritten with component-derived names and unique hashes. This enforces a scoping convention in CSS that avoids most specificity issues.

One small problem

That tooling believes @keyframes rules need to be scoped too.

It makes sense why. Several components could define animations named fadeIn that behave differently. The scoping convention needs to resolve that.

But it’s not obvious how to reuse animations across components anymore.

This is an anti-component idea, but it's something you want if you treat animation definitions as shared tokens in a design system.

First try: import animation CSS in the application

❌ Doesn’t work.

  1. Define a keyframes rule.
  2. Import that animation in the application index.
  3. Reference the imported animation from a component.
  4. Hold your breath…
// css/core/animation/rotateClockwise.css

@keyframes rotateClockwise {
    0%   { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
}
// index.js

import React from 'react';
import './css/animation/rotateClockwise.css';
import Spinner from './component/Spinner';
// component/Spinner/spinner.js

import React from 'react';
import css from './spinner.css';

const Spinner = props => (
    <div className={css.spinner}>
        Spin Me!
    </div>
);

export default Spinner;
// component/Spinner/spinner.css

.root {
    animation-duration: 1s;
    animation-iteration-count: infinite;
    animation-name: rotateClockwise;
    animation-timing-function: linear;
}

What’s wrong?

Enforced scoping prevents the keyframes rule and class’ animation name from matching in the output CSS, so the animation doesn’t apply:

  • Keyframes rule: rotateClockwise-rotateClockwise-HIJ
  • Animation name: spinner-rotateClockwise-XYZ
// Output CSS

@keyframes rotateClockwise-rotateClockwise-HIJ {
    0%   { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
}

.spinner-root-ABC {
    animation-name: spinner-rotateClockwise-XYZ;
}

A lot of people get stung by this behavior:

webpack-contrib/css-loader

css-modules/css-modules

Second try: import animation CSS in the component

❌ Doesn’t work.

This fails for the same reason. The imports are scoped separately, and references won’t match.

// css/core/animation/rotateClockwise.css

@keyframes rotateClockwise {
    0%   { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
}

.root {
    animation-duration: 1s;
    animation-iteration-count: infinite;
    animation-name: rotateClockwise;
    animation-timing-function: linear;
}
// component/Spinner/spinner.js

import React from 'react';
import animation from '../../css/animation/rotateClockwise.css';
import css from './spinner.css';

const Spinner = props => (
    <div className={css.spinner + ' ' + animation.root}>
        Spin Me!
    </div>
);

export default Spinner;

Third try: override scope with global/local flags

✅ It works!

CSS Modules has :global and :local flags (which aren’t CSS) to override the enforced scoping. Beware the docs lack detail and good examples:

I think these are all the supported syntaxes:

  • :global .selector {}
  • .selector :global {}
  • :global(.selector) {}
  • :local .selector {}
  • .selector :local {}
  • :local(.selector) {}
  • @keyframes :global(rotateClockwise) {}
  • @keyframes :local(rotateClockwise) {}

Guess how they work? Like people in GitHub issues, I wasn’t sure. I almost gave up on scope override flags for simpler workarounds. After sleeping on it, I experimented into a working solution:

// css/core/animation/rotateClockwise.css

// Don't scope the rule name (rotateClockwise) to itself.
@keyframes :global(rotateClockwise) {
    0%   { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
}
// component/Spinner/spinner.css

// Don't scope animation name (rotateClockwise) to this component.
.root :global {
    animation-duration: 1s;
    animation-iteration-count: infinite;
    animation-name: rotateClockwise;
    animation-timing-function: linear;
}

Now the keyframe rule is global and reusable in multiple components. I got this working last, so the ideas keep going.

Fourth try: inject CSS properties via composes

✅ It works — but don’t use it. ❌

This is actually pretty slick. Composition in CSS Modules is like @extend in Sass.

// css/core/animation/rotateClockwise.css

@keyframes rotateClockwise {
    0%   { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
}

.root {
    animation-name: rotateClockwise;
}
// component/Spinner/spinner.css

.root {
    composes root from '../../css/animation/rotateClockwise.css';
    animation-duration: 1s;
    animation-iteration-count: infinite;
    animation-timing-function: linear;
}

Instead of generating a grouped selector like Sass, the composed class name is magically appended the element’s class attribute in HTML. This sidesteps the scoping problem by rewriting HTML from CSS.

<!-- Output HTML -->

<div class="spinner-root-ABC rotateClockwise-root-XYZ">
    Spin Me!
</div>

That’s super cool, but probably confusing behavior for CSS Modules beginners.

The pseudo letdown

This technique falls apart when animating ::before and ::after because you can’t compose into pseudo-elements with CSS Modules:

// This doesn't work on ::before or ::after
.root::before {
    composes: root from '../../css/animation/rotateClockwise';
    animation-duration: 1s;
    animation-iteration-count: infinite;
    animation-timing-function: linear;
}

More confusing than it’s worth

To animate pseudo-elements with composition, you have to compose on the element (not the pseudo-element). That means you need three classes to compose a keyframe rule:

// css/core/animation/rotateClockwise.css

@keyframes rotateClockwise {
    0%   { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
}

// Compose this from ".root" to animate ".root"
.root {
    animation-name: rotateClockwise;
}

// Compose this from ".root" to animate ".root::before"
.rootBefore::before {
    animation-name: rotateClockwise;
}

// Compose this from ".root" to animate ".root::after"
.rootAfter::after {
    animation-name: rotateClockwise;
}

The element is composed from the class targeting the pseudo-element ::before.

// component/Spinner/spinner.css

// Compose "rootBefore" on the element...
.root {
    composes: rootBefore from '../../css/animation/rotateClockwise.css';
}

// ...so the property applies to "::before" of the element. Ugh.
.root::before {
    animation-duration: 1s;
    animation-iteration-count: infinite;
    animation-timing-function: linear;
}

This is weird and hard to trace even if you understand what composition does in a pseudo-element context.

If the class name I chose (rootBefore) didn’t suggest the composition actually targets .root::before this wouldn’t be readable at all.

Avoid composing keyframe rules. Use more obvious techniques.

Fifth try: don’t fight scoping

✅ It works!

Enforced scoping wants you to give up reusable globals. Just copy the keyframe rules into each component’s CSS and live with minor redundancy.

// component/Spinner/spinner.css

@keyframes rotateClockwise {
    0%   { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
}

.spinner {
    animation-duration: 1s;
    animation-iteration-count: infinite;
    animation-name: rotateClockwise;
    animation-timing-function: linear;
}

It’s just hard to keep consistent, unless…

Remember when Ant-Man went sub-atomic in the Quantum Realm…

Reuse the properties not the rule

Enter CSS custom properties. Use the :root namespace (which is global) to abstract the values from a specific keyframe rule:

// index.css

:root {
    --animation-avenge-0-transform: rotate(0deg) scale(1);
    --animation-avenge-10-transform: rotate(-45deg) scale(1.1);
    --animation-avenge-100-transform: rotate(10000deg) scale(0.0000000001);
    --animation-avenge-timing: ease-in;
}

Then across several components, apply the custom properties for consistency:

// component/AntMan/antMan.css

@keyframes avenge {
    0%   { transform: var(--animation-avenge-0-transform); }
    10%  { transform: var(--animation-avenge-10-transform); }
    100% { transform: var(--animation-avenge-100-transform); }
}

.root {
    animation-delay: 2s;
    animation-direction: alternate;
    animation-duration: 3s;
    animation-iteration-count: infinite;
    animation-name: avenge;
    animation-timing-function: var(--animation-avenge-timing);
}
// component/CaptainAmerica/captainAmerica.css

@keyframes avenge {
    0%   { transform: var(--animation-avenge-0-transform); }
    10%  { transform: var(--animation-avenge-10-transform); }
    100% { transform: var(--animation-avenge-100-transform); }
}

.root {
    animation-delay: 2s;
    animation-direction: alternate;
    animation-duration: 3s;
    animation-iteration-count: infinite;
    animation-name: avenge;
    animation-timing-function: var(--animation-avenge-timing);
}

It’s less redundant now and more consistent. Not perfectly reusable, but might help in a pinch.

And who didn’t want to see that animation?

See the Pen CSS — Ant-Man to the Quantum Realm by Brendan Falkowski (@brendanfalkowski) on CodePen.

Is scoping worth it?

  • Non-scoped CSS is complicated.
  • Scoped CSS is complicated.

It’s better and worse. Does scoping make reusing animations easier? No, but you probably still want scoping.

I’ll probably be using both these approaches:

  • Third try: override scope with global/local flags
  • Fifth try: don’t fight scoping

Discourse Gravitated