DEV Community

Alexander
Alexander

Posted on

Building a CSS motion token pipeline for custom easing curves

You open a pull request for a new modal component. The developer wrote transition: all 0.3s ease-in-out. You check the dropdown component that was merged yesterday. That one uses transition: opacity 150ms linear. Then you look at the drawer component. It has a custom cubic-bezier(0.4, 0, 0.2, 1) with a 500ms duration.

The motion design is a complete mess. The app feels jittery and disconnected because every developer is just guessing the animation timing.

Motion is usually the last thing teams formalise. We nail down our colours and typography early on. But animations just slip through the cracks. The result is a codebase full of magic numbers and inconsistent transitions.

Good motion design is not just about making things look pretty. It provides crucial spatial context to the user. When a drawer slides in from the right it tells the user where that information lives. When a button subtly scales down on click it confirms the system received the input. But when these timings are inconsistent the application feels cheap. It breaks the illusion of a solid digital product.

Today we are going to fix this by building a dedicated motion token pipeline. We will map specific durations and easing curves to CSS variables. This creates a single source of truth for all animations in your app.

Prerequisites

You need Node installed on your machine. You also need a basic understanding of JSON structure. We will use Style Dictionary to compile our tokens into usable code. Make sure you have an empty project folder ready to go in your terminal.

Step 1: Defining motion primitives

The biggest mistake developers make is jumping straight into semantic names like modal-enter. We need primitive values first. These are the raw ingredients for our animations.

Create a file called motion.json in your project root. We will define two distinct concepts here. First we need durations for how long things take. Then we need easing curves for how things accelerate and decelerate.

In modern UI design we typically split easing into two categories. Productive easing is for small quick interactions like button hovers or checkboxes. Expressive easing is for larger structural changes like opening a full screen menu or a large modal.

Here is what our primitive JSON looks like.

{
  "motion": {
    "duration": {
      "fast": { "value": "150ms" },
      "normal": { "value": "250ms" },
      "slow": { "value": "400ms" }
    },
    "easing": {
      "productive": { "value": "cubic-bezier(0.2, 0, 0.38, 0.9)" },
      "expressive": { "value": "cubic-bezier(0.4, 0.14, 0.3, 1)" },
      "linear": { "value": "linear" }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Compiling to CSS

Now we need to turn this JSON file into CSS custom properties. We will use Style Dictionary for this heavy lifting.

First you need to install the package. Run npm install style-dictionary in your terminal.

Then create a build script called build.js in the same folder. This script tells Style Dictionary where to find our JSON and how to format the output. We want standard CSS variables that we can use anywhere.

const StyleDictionary = require('style-dictionary');

const config = {
  source: ['*.json'],
  platforms: {
    css: {
      transformGroup: 'css',
      buildPath: 'build/css/',
      files: [{
        destination: 'variables.css',
        format: 'css/variables'
      }]
    }
  }
};

const sd = StyleDictionary.extend(config);
sd.buildAllPlatforms();
Enter fullscreen mode Exit fullscreen mode

Run this script by typing node build.js in your terminal. This generates a clean CSS file inside a new build folder. But we are not entirely done yet.

Step 3: Creating semantic motion roles

Primitives are great but they are not enough for a large team. A developer should not have to guess if a tooltip needs a fast or slow duration. We need semantic roles that tell developers exactly what variable to use for a specific context.

Let us create a new file called semantic-motion.json. This file will reference our primitives using aliases. Aliasing is a powerful concept. It means if we ever change the raw duration of our fast token it will automatically update everywhere the alias is used.

{
  "motion": {
    "semantic": {
      "hover": {
        "duration": { "value": "{motion.duration.fast.value}" },
        "easing": { "value": "{motion.easing.productive.value}" }
      },
      "modal": {
        "enter": {
          "duration": { "value": "{motion.duration.normal.value}" },
          "easing": { "value": "{motion.easing.expressive.value}" }
        },
        "exit": {
          "duration": { "value": "{motion.duration.fast.value}" },
          "easing": { "value": "{motion.easing.productive.value}" }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice how the modal exit is faster than the modal enter. This is a common pattern in motion design. Users want things to disappear quickly when they dismiss them. But they need a bit more time to process new information entering the screen.

Step 4: Integrating with Tailwind CSS

If your team uses Tailwind you can easily map these CSS variables into your configuration file. This gives you utility classes that match your design system perfectly.

Open your tailwind.config.js file and extend the theme section. You can map your custom variables to the transition duration and transition timing function properties.

module.exports = {
  theme: {
    extend: {
      transitionDuration: {
        'modal-enter': 'var(--motion-semantic-modal-enter-duration)',
        'modal-exit': 'var(--motion-semantic-modal-exit-duration)',
        'hover': 'var(--motion-semantic-hover-duration)',
      },
      transitionTimingFunction: {
        'expressive': 'var(--motion-semantic-modal-enter-easing)',
        'productive': 'var(--motion-semantic-hover-easing)',
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Let us look at how this actually looks in a React component. With our Tailwind config updated we can build a modal that perfectly matches the design specifications.

export function Modal({ isOpen, children }) {
  return (
    <div 
      className={`
        fixed inset-0 bg-black/50
        transition-all duration-modal-enter ease-expressive
        ${isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95'}
      `}
    >
      <div className="bg-white p-6 rounded-lg">
        {children}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice how clean this is. The developer does not need to know that the expressive curve is a complex cubic bezier string. They just know they are building a modal so they use the modal tokens. This is the exact workflow we want to achieve.

The final output

When you run your build script again with both JSON files included you get a beautiful CSS cascade. The semantic variables point directly to the primitive variables.

:root {
  --motion-duration-fast: 150ms;
  --motion-duration-normal: 250ms;
  --motion-duration-slow: 400ms;
  --motion-easing-productive: cubic-bezier(0.2, 0, 0.38, 0.9);
  --motion-easing-expressive: cubic-bezier(0.4, 0.14, 0.3, 1);
  --motion-easing-linear: linear;

  --motion-semantic-hover-duration: var(--motion-duration-fast);
  --motion-semantic-hover-easing: var(--motion-easing-productive);

  --motion-semantic-modal-enter-duration: var(--motion-duration-normal);
  --motion-semantic-modal-enter-easing: var(--motion-easing-expressive);
  --motion-semantic-modal-exit-duration: var(--motion-duration-fast);
  --motion-semantic-modal-exit-easing: var(--motion-easing-productive);
}
Enter fullscreen mode Exit fullscreen mode

This approach completely changes how a team handles animations. You stop arguing about milliseconds in code reviews. The design system dictates the physics of your application. Everything feels unified and intentional.

Honestly I got really tired of manually updating these JSON files every time a designer tweaked an easing curve. The back and forth was exhausting and prone to human error. So basically I built a tool to automate this entire process. It is a Figma plugin called Design System Sync. It exports your Figma variables directly to GitHub as pull requests. It handles all the formatting for CSS and Style Dictionary automatically. You get 5 free exports a month if you want to try it out. You can grab it from the Figma Community or check out the website for more details.

Top comments (1)

Collapse
 
phongdesigns profile image
Phong Designs AI System

One reason motion stays unformalised longer than colour or type: it's invisible in the artifacts that get reviewed. A wrong hex shows up in a screenshot. A wrong duration doesn't show up anywhere until the build is running, and by then it reads as taste rather than error. Nothing to diff, so the drift never gets caught.

Which I'd argue is what this pipeline actually fixes, more than consistency: it makes motion reviewable. A PR that changes modal enter from normal to slow is a visible, arguable decision. A transition: all 0.3s ease-in-out buried in a component never gets argued about even once.

The enter/exit asymmetry is a good tell for system health too. When exit is just "reverse the enter", the semantic layer is usually decorative — someone mapped names onto values without any behaviour actually depending on the distinction. Your modal example encoding exit-faster-than-enter is the semantic layer doing real work.