Atomic CSS-Based Icon Animation Solution

5/31/2026

I. Background

A redesign of an enterprise-grade backend system's sidebar required adding micro-interaction animations to sidebar icons. The system has approximately 200 business modules, with over 2,000 icons in the sidebar.

As you can imagine, editing animations one by one using traditional After Effects tools would be an enormous amount of work.

Therefore, a solution was needed to quickly generate icon animations while maintaining consistency across all animations.

II. Technology Evaluation

Before starting, we evaluated the mainstream icon animation solutions available:

SolutionProsCons
GIFGood compatibility, WYSIWYGLarge file size, fixed resolution, difficult dark mode adaptation, no interactive control
LottieRich animations, supports complex pathsRequires JSON file + lottie-web (~60KB), separate from CSS ecosystem, rendering overhead
CSSVector lossless, small footprint, no JS runtime, integrates with TailwindLimited expressive power for complex animations

Console icon animations mainly consist of translation, rotation, scaling, stroke drawing, and opacity changes — most of which CSS can handle. However, using Lottie for 2,000 icons would mean attaching a JSON file + runtime to each, resulting in unacceptable bundle size; using GIF would make dark mode impossible to handle. Neither of these two options was viable from the start.

Conclusion: implement animations using a CSS-based approach.

III. Atomic CSS Animations

3.1 Atomization

After deciding on the underlying technology, we still needed to figure out how to implement icon animations.

The "scale" problem remained: with 2,000 icons, writing keyframes and animations one by one would be no different from the original workload. So we needed a CSS-based solution for quickly creating animations.

Tailwind CSS's atomic approach inspired me: break animations down into the smallest units, and let designers assemble them. Just as Tailwind uses flex, p-4, and text-sm to build UIs, animations should be composable using scale-75, rotate-12, and similar utilities.

Moreover, AI excels at selecting and combining from a predefined set of parts — it's much more stable than writing keyframes from scratch.

Following this direction, I discovered tailwindcss-motion, an atomic animation library for Tailwind v3.

The animation taxonomy and preset values in tailwindcss-motion provided a solid starting point, saving the effort of defining everything from scratch. However, it was built on Tailwind v3's JS plugin architecture (matchUtilities + addBase), which couldn't be used directly in Tailwind v4's pure CSS-based system. I used Claude Code to rewrite the entire library into Tailwind v4's @utility + CSS Variable implementation, while trimming and extending it for SVG icon animation scenarios:

Additions:

  • Stroke (draw): An animation type not present in tailwindcss-motion. Achieves line drawing/growth via stroke-dashoffset, requiring SVG elements to have pathLength="1". Three slots were added: draw-in, draw-out, and draw-loop.

    A small trick in the stroke animation implementation: stroke-dasharray isn't set exactly to 1, but to 1.1, with an initial offset of 0.05 for stroke-dashoffset. The reason is that when dasharray exactly equals the path length, a slight flicker occurs at the end of the animation due to precision issues (browsers have unstable subpixel rendering near boundary values). By slightly exceeding the path length and offsetting the starting point, the dash never touches that critical boundary, and the flicker disappears.

  • Shake: Built-in multi-level decaying oscillation in keyframes (amplitude decays stepwise: -0.5, 0.25, -0.1, 0), reusing the rotate slot without adding new CSS variables.

  • Reverse animations: Supports negative prefix syntax, e.g., -motion-translate-x-in-50 (reverse translation), -motion-draw-in-100 (draw from path end in reverse). More intuitive for both AI generation and manual writing.

  • Trigger mechanism: tailwindcss-motion's animations play automatically on element mount. This solution adds a data-motion-trigger attribute system supporting hover and click triggers. When not triggered, animation-name: none !important completely suppresses the animation, with no reliance on JS events.

  • SVG transform-origin fix: SVG elements default to transform-origin: 0 0 (top-left), while HTML elements default to 50% 50% (center). It automatically sets transform-box: fill-box within the [data-motion-icon] scope, ensuring rotation and scaling are centered around the element itself.

Removals:

  • Preset system: tailwindcss-motion has 30+ pre-composed animation presets (motion-preset-fade, motion-preset-slide, etc.). In this solution, this responsibility is handed to AI — the AI composes solutions from atomic parts based on icon semantics, rather than humans selecting from presets.
  • Filter, text-color, and background-color animations: Not needed in icon animation scenarios, removed directly to reduce slot count and CSS size.
  • Loop default changed from infinite to 1: Admin dashboard icon looping animations usually only need a finite number of repetitions (e.g., a gear rotating one or two turns then stopping). Infinite loops require explicitly adding motion-loop-infinite.

Adjustments:

  • Default duration reduced from 700ms to 300ms, better suited for micro-interaction pacing.
  • Added spring easing based on linear() (spring-smooth/snappy/bouncy/bouncier/bounciest), from kvin.me/css-springs.
  • All @keyframes wrapped in @media (prefers-reduced-motion: no-preference) (tailwindcss-motion only wrapped transform classes; color/opacity classes were not wrapped).

Overall, tailwindcss-motion is a general-purpose web animation library. This project trims and enhances it based on its slot architecture, specifically for SVG icon scenarios.

Here are all currently supported animation effects:

EffectEnterExitLoopNotes
TranslateFly in from top/bottom/left/rightFly out to top/bottom/left/rightFloat up/down/left/rightSupports reverse direction
ScaleScale from small to largeScale from large to smallPulse, breathe effectSupports single-axis scaling
RotateRotate inRotate outContinuous rotationSupports reverse rotation
ShakeShake inShake outContinuous shakeDamped oscillation effect
FadeFade inFade outBlink
Stroke DrawLine drawing inLine erasingLine breathingSVG path animation

Supports 22 easing curves, including standard easing, back, spring, and bounce effects. Default duration is 300ms, with configurable delay, loop count, and transform origin.

3.2 The Animation Slot Solution in tailwindcss-motion

tailwindcss-motion's core design solves a key problem: how to make multiple animation utility classes coexist without overriding each other. (Thanks to @romboHQ for this contribution.)

Writing two animations for an element is easy in native CSS:

.element {
  animation:
    scale-in 0.3s,
    rotate-in 0.3s;
}

But if these two animations need to be composed via utility classes (animate-scale-in animate-rotate-in), CSS cascade logic causes the later animation declaration to completely override the former, rather than appending.

A bigger problem is combining entrance animations with loop animations. An element first flies in from the left (in), then continues to move subtly in place (loop). in is one-shot; loop is continuous. Using two independent class names — CSS doesn't support this, because the animation property is replaced as a whole, not appended. This is a limitation at the CSS specification level.

3.3 Animation Slots

tailwindcss-motion's solution is to pre-declare all slots. Each utility class only writes animation values into its corresponding slot, filling the remaining slots with none. Essentially, it uses CSS variable fallback mechanisms for multiplexing, which works perfectly in animation scenarios:

PhaseSlotsProperties
enter1–6scale-in, translate-in, rotate-in, opacity-in, bg-color-in, draw-in
exit7–12scale-out, translate-out, rotate-out, opacity-out, bg-color-out, draw-out
loop13–18scale-loop, translate-loop, rotate-loop, opacity-loop, bg-color-loop, draw-loop

6 properties × 3 phases = 18 slots. The concept is as follows:

@utility motion-translate-x-in-* {
  --motion-translate-in-animation: /* actual animation value */;
  animation:
    /* slot 1-6 (enter) */
    var(--motion-scale-in-animation),
    /* → none */ var(--motion-translate-in-animation); /* → actual animation */
  /* ... remaining 16 slots follow the same pattern */
}

Actual code (Tailwind v4 @utility):

@utility motion-translate-x-in-* {
  --motion-origin-translate-x: --value(--motion-translate-*, [percentage], [length]);
  --motion-translate-in-animation: motion-translate-in
    calc(
      var(--motion-translate-duration, var(--motion-duration)) *
        var(--motion-perceptual-duration-multiplier)
    )
    var(--motion-translate-timing, var(--motion-timing))
    var(--motion-translate-delay, var(--motion-delay)) both;
 
  animation:
    var(--motion-scale-in-animation), var(--motion-translate-in-animation),
    var(--motion-rotate-in-animation), var(--motion-opacity-in-animation),
    var(--motion-background-color-in-animation), var(--motion-draw-in-animation),
    var(--motion-scale-out-animation), var(--motion-translate-out-animation),
    var(--motion-rotate-out-animation), var(--motion-opacity-out-animation),
    var(--motion-background-color-out-animation), var(--motion-draw-out-animation),
    var(--motion-scale-loop-animation), var(--motion-translate-loop-animation),
    var(--motion-rotate-loop-animation), var(--motion-opacity-loop-animation),
    var(--motion-background-color-loop-animation), var(--motion-draw-loop-animation);
}

All utility classes share the same 18-slot animation declaration. When motion-scale-in-75 motion-translate-x-loop-25 are used together, each class only writes values into its own slot without overriding each other.

The loop slots use animation-composition: accumulate, allowing loop animation transforms to accumulate on top of the final state of the entrance animation, rather than starting from zero. This way, elements can complete their entrance animation first, then continue looping from their current position, with both phases transitioning naturally.

3.4 Arbitrary Values

When preset values aren't enough, you can use bracket syntax to specify arbitrary CSS values:

<g class="motion-translate-x-in-[12px] motion-rotate-loop-[30deg]">
  <path d="..." pathLength="1" />
</g>

Tailwind v4's --value() extracts values from class names and maps them directly to CSS variables.

3.5 Accessibility

All keyframes are wrapped in @media (prefers-reduced-motion: no-preference). When users disable animations in system settings, animations automatically become silent.

3.6 Trigger Methods

Once animations are defined, you need to control when they play. Atomic animations default to playing entrance animations automatically when elements mount, with loop animations following immediately. But in real-world scenarios, hover triggers are the most common use case — when the user's mouse enters an icon, the entrance animation plays; when it leaves, the exit animation plays.

The design principle here is to keep logic in the CSS layer as much as possible. The trigger mechanism is implemented via the data-motion-trigger attribute. CSS attribute selectors control animation start/stop states:

[data-motion-trigger="hover"]:hover {
  --motion-trigger: running;
}

Correspondingly, when used in React, motion-react provides a minimal hook:

import { useHoverMotionTrigger } from "motion-react/hooks";
 
function MyIcon() {
  const hoverProps = useHoverMotionTrigger<HTMLDivElement>();
  return (
    <div {...hoverProps}>
      <SettingsIcon />
    </div>
  );
}

useHoverMotionTrigger does something very simple: it returns a ref and a data-motion-trigger="hover" attribute, leaving everything else to CSS. No mouseenter/mouseleave listeners, no state toggling, no JS animation logic.

The benefit of this approach is zero runtime overhead — animation playback and pause are entirely handled by the browser's CSS engine, without triggering React re-renders or requiring JS to manually calculate animation progress. This is especially important for scenarios with 2,000+ icons appearing simultaneously in the sidebar.

IV. Project Structure

This project is a monorepo containing three parts:

css-motion-system/
├── apps/studio/           # Next.js web application
├── packages/
│   ├── motion/            # Pure CSS animation library
│   └── motion-react/      # React icon components

The dependency chain is studio → motion-react → motion, one-way with no cycles.

motion has no build step; CSS is published directly. motion-react is bundled via tsup for ESM/CJS and type definitions. The studio hosts the AI workflow. Code formatting and linting use Oxfmt + Oxlint (Rust implementations).

V. Web UI Workflow

motion-web-ui

The Studio application breaks icon animation into four steps. The core idea is that AI and humans each do what they do best: fully automated AI quality is unstable, and pure manual workshop methods don't scale. Combining both is the viable path.

Step 1: Upload SVG. Drag and drop an SVG file.

Step 2: SVGO optimization + semantic grouping. SVGO performs standardized cleanup: removing redundant nodes, unifying attribute order, adding pathLength="1" to paths (preparing for stroke animations). Then AI steps in, using Gemini 3.1 Flash for multimodal analysis, taking SVG source code and rendered images as input, and outputting grouping suggestions. For example, for a gear icon, AI can identify two semantic groups: "outer teeth" and "center hole":

<svg viewBox="0 0 24 24">
  <g data-group="outer teeth">
    <path d="..." />
  </g>
  <g data-group="center hole">
    <circle ... />
  </g>
</svg>

The editor supports overlay comparison, with different groups shown in different colors, and manual drag-and-drop adjustments. This forms a closed loop of AI rough grouping + human fine-tuning.

Step 3: Generate animation plan. Designers can manually select animation types, duration, easing curves, and loop counts, with all changes previewed in real time.

motion-web-ui-2

In this step, AI can also generate three animation plans based on semantics. Taking a gear icon as an example:

  • Plan A: Outer teeth rotate slowly (loop), center hole fades in (in)
  • Plan B: Entire icon slides in from the left (translate-in), rotates 90° (rotate-in)
  • Plan C: Stroke drawing growth (draw-in), with slight scale bounce (scale-in + spring-bouncy)

After selecting a plan, you can still manually adjust: modify animation type, duration, easing curve, loop count — all changes previewed in real time.

Step 4: Export. You can choose to export:

  • React TSX component with types, size/strokeWidth props, and "use client" at the top for RSC compatibility
  • Standalone SVG with embedded CSS, usable in non-React scenarios

TSX files are written directly into the motion-react source directory and can be published to npm after building.

VI. Skill Workflow

In addition to the Web UI, the entire workflow is encapsulated as an AI conversational tool. All steps are completed via natural language interaction: paste SVG → SVGO optimization → AI grouping → three plans → selection and fine-tuning → generate TSX → write to component package. No window switching, no uploading or downloading.

Uploading files to a webpage is actually an extra step for developers; pasting SVG code directly in the terminal and getting results is more efficient. Moreover, the Skill can use sub-agents to add animations to multiple icons simultaneously, making it even more efficient.

Skill and GUI workflows complement each other: quickly iterate through plans first, then precisely adjust individual icons.

VII. Summary

This solution features the following:

  • Updated and extended the tailwindcss-motion design, making it more suitable for SVG icon animation scenarios
  • A motion-react library providing React icon components and hooks
  • A Web UI workflow in the studio application, supporting AI-assisted icon animation generation
  • An AI conversational workflow supporting completion of all steps via natural language interaction

VIII. Acknowledgments

Thanks to romboHQ's tailwindcss-motion for its contribution to this project, and to Tailwind CSS for the inspiration.