Making SVGs Work With Your CSS

SVG has been a mainstay of web development for years, and for good reason. As a vector format, it scales flawlessly, and because it is XML-based, it’s compressible and easy to optimize. But its real strength is that it’s fundamentally a markup language. That means we can use the same CSS and JavaScript techniques we rely on for HTML to style, script, and animate it. This opens the door to adding genuine personality and interactivity to graphics—turning what might be a static image into a memorable part of the user experience.

This creative potential is often left untapped. Many developers assume that animating SVG is a heavy time investment or requires a deep mastery of graphic design plus a complex JavaScript animation library. Often, the most effective enhancements come from a few lines of plain CSS and vanilla JavaScript, a subject we’ll explore in practice here.

Using currentColor for Flexible Icon Styling

A common frustration is the "one color fits all" icon. Design files and icon libraries often export SVGs with hard-coded color values, such as fill="#C2CCDE" on a ``. A typical remedy is a greedy CSS override like the following:

.button svg * { fill: #ffffff; }

While this works, it's brittle. This selector will force a fill color on every sub-element. This can have unintended consequences, especially for multi-colored icons or those where a missing fill is used intentionally. Overriding "the override" to restore original colors in specific contexts leads to specificity wars and duplicated code.

A better approach is to make the SVGs themselves color-agnostic. We can do this by editing the SVG markup to use the extremely useful currentColor CSS value for its visible fills. Often described as "the first CSS variable," currentColor resolves to the value of the element’s color property. By coding our SVG to fill colors this way, we can style them simply by setting the color property in our stylesheets, allowing the graphic to inherit contextual states for hover and focus without any specific selectors targeting the SVG paths.

We can apply this to simple icons as well as complex ones. For an icon like the Google "G", which has multiple colored paths, be sure to only replace the visible colors and avoid altering any elements with fill="none" that are meant to be invisible. Once complete, we can safely remove the generic SVG override selectors from our CSS.

Tools like Figma offer plugins that automate changing these hard-coded fills to currentColor at export time. Useful options include SVG Export and One Click SVG.

Optimizing SVG Markup

Like HTML, SVG markup benefits from minification. Optimizing can remove unnecessary metadata, formatting, invisible elements, and extraneous properties. This is typically done automatically as part of your build process. The standard tool here is SVGO, which integrates with most tech stacks.

For quick, manual optimization, Jake Archibald’s web tool SVGOMG offers an excellent graphical interface for tweaking optimization settings and inspecting the output before saving.

Frameworks and Bundle Performance

While JavaScript frameworks like React have integrated SVG support—allowing you to import an SVG file directly as a component—there are performance considerations to keep in mind.

Incorporating SVG as JSX means your JavaScript bundle will stringify the markup. Your browser is no longer just parsing a standard file; it now has to perform the extra work of parsing and evaluating JavaScript—which is the most expensive web resource—to build and then display the graphic. This process adds runtime cost, making what should be a simple graphic as expensive to load as a full script.

A performant workaround involves sprite sheets. Define your SVG icons once in your HTML using the <symbol> element they are placed in an "SVG library". Then, you can instantiate and customize them elsewhere in your code with the <use> element. This keeps the icon code out of your JavaScript bundles, converting it back into a cheap, parseable static asset.

Bringing SVG Graphics To Life With CSS

Animating SVG content doesn't require a full JavaScript animation stack. With a handful of CSS @keyframes, the right easing function, and attention to how the SVG coordinate system behaves, you can add purposeful motion that fits a project's visual style. The examples that follow share a common toolbox: they all control opacity and transform, and each one demonstrates how those two properties can produce dramatically different results.

A cookie banner graphic designed for a project didn't stand out enough on its own. To make it more noticeable without being obtrusive, three separate animations were added: a rolling entry, a repeating wiggle, and an eye sparkle. The result draws attention gently while matching the graphic's whimsical tone.

<figure role="presentation" class="cookie-notice__graphic-container">
  <span class="cookie-notice__shadow"></span>
  <svg class="cookie-notice__graphic" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="300" height="300" viewBox="0 0 223.233 228.464">
    <path fill="#f8c256" d="..." />
    <!-- ... -->
  </svg>
</figure>

The relevant SVG markup features a parent svg element and inner path elements. A separate shadow is handled by an HTML span, but all the animation work happens on the SVG elements themselves.

Creating The Entry Animation

Because the banner sits centered on screen, rolling the cookie from far off-screen would be jarring on wide displays. The fix is an entry that combines opacity with a transform that rotates while moving horizontally:

@keyframes enter {
  0% {
    opacity: 0;
    transform: translate3d(-60%, 0, 0) rotateZ(-50deg);
  }

  100% {
    opacity: 1;
    transform: translate3d(0, 0, 0) rotateZ(17deg);
  }
}

The default linear timing won't work here. A custom cubic-bezier function makes the motion feel playful: allowing the value to overshoot its final position creates a single bounce. Setting the fourth parameter of the cubic bezier above 1 produces this effect, and storing it in a CSS variable makes it reusable for later animations.

--transition-bounce: cubic-bezier(0.2, 0.7, 0.4, 1.65);

That variable then supplies the timing for the bounce combined with a duration and fill-mode:

/* Our SVG element */
.cookie-notice__graphic {
  opacity: 0; /* Should not be visible at the start */
  animation: enter 0.8s var(--transition-bounce) forwards;
}

The overshoot in the easing is what separates this from a flat, mechanical slide into place. It subtly signals that something lightweight and friendly has arrived on screen.

Adding A Referencing Wiggle

A one-time entry won't hold attention later. For a reminder that doesn't interrupt, a repeating wiggle with long pauses between iterations does the job. One design reference for this approach came from an alpaca animation on Dribbble that played a similar ear movement.

Since CSS keyframes don't provide a built-in pause per iteration, the delay is encoded directly into the percentages. Five logical steps are defined within a single set of keyframes: stillness, pull-back, release, return, and stillness again. The motion block looks like this:

@keyframes wiggle {
  0% {}   /* Stands still */
  45% {}  /* Movement starts */
          /* ... */
  60% {}  /* Movement ends */
  100% {} /* Stands still */
}

The easing function is repeated between each pair of keyframe percentage ranges. Applying the same built-in bounce across each segment keeps the character consistent with the entry animation.

/* Our SVG element */
.cookie-notice__graphic {
  opacity: 0;
  animation: enter 0.8s var(--transition-bounce) forwards,
    wiggle 6s 3s var(--transition-bounce) infinite;
}

The visual sequence of those easing segments is what softens the whole effect: the cookie flexes back and forth but always settles cleanly before repeating.

Making The Eyes Sparkle

Both prior animations targeted the svg element itself. To animate inner shapes, the browser's inspector identifies the two circles used for eyes. Adding the CSS class attribute to those paths lets the stylesheet target them directly.

<!-- ... -->
<path fill="#351f17" d="..." />
<path class="cookie__eye" fill="#fff" d="..." />
<path fill="#351f17" d="..." />
<path class="cookie__eye" fill="#fff" d="..." />
<!-- ... -->

A sparkle needs quick, snappy transitions. Staircase functions, achieved via steps(), toggle between animation states with sharp changes rather than smooth interpolation. Scaling and opacity alone define the animation; for short bursts, that's all that's needed.

@keyframes sparkle {
  from {
    opacity: 0.95;
    transform: scale(0.95);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

A crucial detail is the transform-origin. SVG geometry references its parent viewbox unless told otherwise. Setting transform-box: fill-box overrides this, letting the transform relate to the boundary of the selected path itself rather than the entire SVG canvas.

.cookie__eye {
  animation: sparkle 0.15s 1s steps(2, jump-none) infinite alternate;
  transform-box: fill-box;
  transform-origin: center center;
}

If a user has set a preference for reduced motion, all these animations get disabled with a single media query block to keep the experience responsible to the user's accessibility needs.

See the Pen [Animated cookie svg [forked]](https://codepen.io/smashingmag/pen/eYjMvXz) by Adrian Bece.

See the Pen Animated cookie svg [forked] by Adrian Bece.

Applying Transform Entry Animations

The same core methods extend easily to layered SVG compositions, like one with a dark rectangle, a white half circle, vertical lines, and a series of base rectangles. The goal is a coordinated, staged entrance: the dark rectangle drops from above, the half circle rotates in, the vertical lines scale outward with delay, and the base rectangles appear in a staggered sequence.

Targeting elements for the first two shapes means adding class attributes after inspecting the markup:

<svg xmlns="http://www.w3.org/2000/svg"xml:space="preserve" viewBox="0 0 1600 1066">
  <!-- ... -->
  <path class="sun__bg" d="..." style="fill:#363636;fill-opacity:1;fill-rule:nonzero;stroke:none" id="path14" />
  <path class="sun__top" d="..." style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" id="path16" />
  <!-- ... -->
</svg>

Each element gets its own animation settings. For some shapes, exactly like the eye sparkle, the transform origin must be pinned to the specific path rather than the whole SVG:

.sun__bg {
  transform: translateY(100%);
  animation: fromTop 0.5s 1s ease forwards;
  transform-box: fill-box;
}

.sun__top {
  transform: rotateZ(180deg);
  animation: rotateIn 1s 1.4s ease-in-out forwards;
  transform-origin: center top;
  transform-box: fill-box;
}

Managing 17 individual rectangles would be tedious. SVG's group element, <g>, works like a div: wrapping the 17 path elements in one group.

<g class="sun__lines" id="g20" clip-path="url(#clipPath24)">
  <path d="... style="fill:#363636;fill-opacity:1;fill-rule:nonzero;stroke:none" id="path26" />
  <path d="... style="fill:#363636;fill-opacity:1;fill-rule:nonzero;stroke:none" id="path26" />
  <path d="... style="fill:#363636;fill-opacity:1;fill-rule:nonzero;stroke:none" id="path26" />
  <!-- ... -->
 </g>

This provides a single attach point for CSS. Because each rectangle needs a staggered start, the animation is applied as described here:

.sun__lines > path {
  transform: scaleX(0);
  animation: scaleXIn 0.5s 2.5s ease-in-out forwards;
  transform-origin: center center;
  transform-box: fill-box;
}

The final visual polish comes via delays assigned to each path child in sequence:

.sun__lines > path:nth-child(2) {
  animation-delay: 2.6s;
}

.sun__lines > path:nth-child(3) {
  animation-delay: 2.7s;
}

/* ... */

Stacking a g-wrapper with targeted child CSS is more sustainable than adding 17 individual attributes, and the visual outcome is the same as laborious per-element styling.

The result is an elegant four-part entrance that preserves geometry and order. Check the compiled demo for fine-tuning timing and easing for other effects:

See the Pen [Animated SVG graphic [forked]](https://codepen.io/smashingmag/pen/GRBxmRP) by Adrian Bece.

See the Pen Animated SVG graphic [forked] by Adrian Bece.

Animating Decorative SVG Backgrounds

The same principles apply to graphics used as large visual filler. Hero backgrounds built from dozens of repeated geometric shapes benefit from subtle activity. Here the SVG is composed entirely of circle elements.

Image consisting of text content and background SVG
Our hero image consists of text content and background SVG. (Large preview)

Without any transforms, this can read as flat static decoration. Rather than carefully aligning each element to the parent viewbox, that viewbox displacement becomes the advantage. Because CSS transforms originate from the SVG's shared center, even simple scale changes create chaotic and organic movement across many shapes. Leaving the reference box unchanged turns the math into a feature, not a bug.

Using a preprocessor like SASS makes assigning animations across dozens of elements more expedient; the CodePen includes compiled CSS for reference:

svg circle {
  opacity: 0.85;

  &:nth-child(2n) {
    transform: scale3d(0.75, 0.75, 0.75);
    opacity: 0.3;
}

Two sets of keyframes are responsible for shifting the scale and opacity of the circles:

@keyframes a {
  0% {
    opacity: 0.8;
    transform: scale3d(1, 1, 1);
  }
  100% {
    opacity: 0.3;
    transform: scale3d(0.75, 0.75, 0.75);
  }
}

@keyframes b {
  0% {
    transform: scale3d(0.75, 0.75 0.75);
    opacity: 0.3;
  }
  100% {
    opacity: 0.8;
    transform: scale3d(1, 1, 1);
  }
}

Alternative patterns and delays are sifted through with :nth-child terminology. Odd children animate to keyframes set a; even kids use set b:

svg circle {
  opacity: 0.85;
  animation: a 10s cubic-bezier(0.45,0.05,0.55,0.95) alternate infinite;

  &:nth-child(2n) {
    transform: scale3d(0.75, 0.75, 0.75);
    opacity: 0.3;

    animation-name: b;
    animation-duration: 6s;
    animation-delay: 0.5s;
  }

  &:nth-child(3n) {
    animation-duration: 4s;
    animation-delay: 0.25s;
  }

  /* ... */
}

By cycling between durations with the selectors, a random drift effect across all circles is formed in CSS alone:

See the Pen [Animated welcome screen [forked]](https://codepen.io/smashingmag/pen/OJwvmWK) by Adrian Bece.

See the Pen Animated welcome screen [forked] by Adrian Bece.

The hero section only gets more dynamic since children, durations and delays work together to generate variation on one repeated decorative motif.

Using An SVG As A Background Image

If the SVG is decorative, converting it to a data URI also enables background animation. One reusable circle pattern:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50" fill-opacity=".03"/></svg>

Converting to base64 yields a fully browser-embeddable CSS background-image snippet:

background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48Y2lyY2xlIGN4PSI1MCIgY3k9IjUwIiByPSI1MCIgZmlsbC1vcGFjaXR5PSIuMDMiLz48L3N2Zz4=);

With that off-screen reference intact, a plain CSS animation explicitly offsets the background-position while keeping the background's size identical to it. This marquee-like offset gives coherent, sliding motion and is a stripped-down way to animate a repeating visual motif.

.wrapper {
  animation: move-background 3.5s linear;
  background-image: url(data:image/svg+xml;base64,...);
  background-size: 96px;
  background-color: #16a757;
  /* ... */
}

@keyframes move-background {
  from {
    background-position: 0 0;
  }

  to {
    background-position: 96px 0;
  }
}

Ensure stylesheet payload size stays reasonable when inlining base64. And as always, a prefers-reduced-motion block can disable this decorative effect for users who prefer not to see continuous screen movement:

See the Pen [Animating background SVG pattern [forked]](https://codepen.io/smashingmag/pen/mdjxmXZ) by Adrian Bece.

See the Pen Animating background SVG pattern [forked] by Adrian Bece.

Building The Self-Drawing Effect

A completely different trick powers self-drawing and self-erasing strokes. These rely on two specific SVG paint properties rather than opacity or transforms.

stroke-dasharray slices a path into segments separated by defined gaps;

Text with stroke converted into dashes
Converting stroke into dashes with the length of 10 pixels with CSS stroke-dasharray: 10. (Large preview)

while stroke-dashoffset shifts where those segments begin along the path:

Strokes with a slight dash offset
Adding a slight 10px dash offset with CSS stroke-dashoffset: 10. (Large preview)

If the dash is long enough to encompass the entire line but offset is equally long, the stroke is pushed out of visible area entirely:

svg path {
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 800;  /* Dash covering the whole stroke */
  stroke-dashoffset: 800; /* Offset it to make it invisible */
}

Animating the dash offset back to zero makes the path trace along through its own line:

svg path {
  /* ... */
  animation: draw 6s linear infinite;
}

@keyframes draw{
  to {
    stroke-dashoffset: 0; /* Reduce offset to make it visible */
  }
}

Continuing into negative offset values then reveals the erase effect:

svg path {
  /* ... */
  animation: drawAndErase 6s linear infinite;
}

@keyframes drawAndErase {
  to {
    stroke-dashoffset: -800;
  }
}

The target offset value corresponds to your content's stroke length; this particular SVG uses 800 px. Guessing is possible and covered in a widely linked tool by Chris Coyier, though bespoke stroke widths or precise intersection points may still find that function approximate.

The text example highlights how these properties act best on any stroke-marked content from clean line art to ornamented text:

See the Pen [SVG stroke animation [forked]](https://codepen.io/smashingmag/pen/LYBdLNK) by Adrian Bece.

See the Pen SVG stroke animation [forked] by Adrian Bece.

Putting It All Together: The Smashing Cat

To demonstrate the full potential of the techniques covered so far, let’s animate the Smashing Magazine cat mascot. The first example combines everything into a single, cohesive animation; the second introduces JavaScript-driven interactivity.

A Combined Animation Workflow

This demo builds on the same principles as the earlier examples—grouping elements, adding classes, and defining keyframes—but scales it to include multiple moving parts. Use the browser’s inspector to identify and select elements, wrap them in groups if necessary, apply a class, and define the animation.

See the Pen [Smashing cat animated [forked]](https://codepen.io/smashingmag/pen/xxJWrOB) by Adrian Bece.

See the Pen Smashing cat animated [forked] by Adrian Bece.

The trickiest part is the pipe animation, which must follow the mouth’s contours precisely. This is done by manually tweaking transform values until the movement aligns perfectly with the graphic.

.pipe {
  transform-box: fill-box;
  transform-origin: top left;
  animation: pipeMove 4s ease-in-out infinite alternate;
}

@keyframes pipeMove {
  from {
    transform: translate3d(4px, -12px, 0) rotateZ(-5deg);
  }
  to {
    transform: translate3d(-5px, 12px, 0) rotateZ(5deg);
  }
}

Adding Interactivity With JavaScript

For a more engaging experience, we can make the cat responsive to user input. Let’s build a barista cat with the following interactive features:

  1. The eyes follow the cursor.
  2. The hat animates on click.
  3. The bowtie animates on click.
  4. The coffee machine pours coffee when its handle is clicked.
Smashing cat playing a barista
Just looking at awesome these SVGs sparks so many animation ideas! (Large preview)

For the eye-tracking behavior, we’ll use the watching-you library rather than writing the math from scratch. Simply inspect the SVG, add the eye-left and eye-right classes to the respective pupil elements, and configure the library to target them.

<ellipse class="cls-5 eye eye-left" cx="245.15133" cy="134.57033" rx="5.31264" ry="8.61816" transform="translate(-33.47349 110.5587) rotate(-23.83807)" />
<ellipse class="cls-4 eye eye-right" cx="284.42686" cy="116.68559" rx="5.31264" ry="8.61816" transform="translate(-22.89477 124.9063) rotate(-23.83807)" />
const optionsLeft = { power: 4, rotatable: false };
const watcherLeft = new WatchingYou(".eye-left", optionsLeft);
watcherLeft.start();

const optionsRight = { power: 3, rotatable: false };
const watcherRight = new WatchingYou(".eye-right", optionsRight);
watcherRight.start();

As with transforms earlier, set transform-box on the eyes so they rotate around their own center rather than the SVG origin.

.eye {
  transform-box: fill-box;
  transform-origin: center center;
}

The hat and bowtie animations follow the same pattern. For the hat, group its two path elements, apply transform-box, and define a keyframe animation tied to an hat--active class.

<g class="hat">
  <path class="cls-6" d="..." />
  <path class="cls-9" d="..." />
</g>
.hat {
  transform-box: fill-box;
  transform-origin: center bottom;
  cursor: pointer;
}

.hat--active {
  animation: hatJump 1s cubic-bezier(0, 0.7, 0.5, 1.25);
}

@keyframes hatJump {
  0% {
    transform: rotateZ(0) translateY(0);
  }

  50% {
    transform: rotateZ(-10deg) translateY(-50%);
  }

  100% {
    transform: rotateZ(0) translateY(0);
  }
}

A click listener toggles the active class on and removes it once the animation completes, allowing repeated triggers.

const hat = document.querySelector(".hat");

hat.addEventListener("click", function () {
  if (hat.classList.contains("hat--active")) {
    return;
  }
  // Add the active class.
  hat.classList.add("hat--active");
  
  // Remove the active class after 1.2s.
  setTimeout(function () {
    hat.classList.remove("hat--active");
  }, 1200);
});

The bowtie uses the identical mechanism with its own class and keyframes.

For the coffee machine, there is no existing coffee stream element, so we create one. Rather than drawing from scratch, duplicate the machine’s pipe rectangle—already close in shape—change its fill to brown, and adjust the size slightly.

<!-- Pipe -->
<rect class="cls-12" x="137.81171" y="243.99883" width="6.21967" height="12.29272" transform="translate(281.84309 500.29037) rotate(-180)" />

<!-- Copied and adjusted Pipe rect to act as a coffee -->
<rect class="coffee" x="139" y="243.99883" width="4" height="12.29272" transform="translate(281.84309 500.29037) rotate(-180)" fill="brown" />

Define two keyframe animations for the pour and combine them on a single element, tuning duration and delay.

.lever, .coffee {
  transform-box: fill-box;
  transform-origin: center bottom;
}

.lever {   
  cursor: pointer; 
}

.lever--active {
  animation: leverPush 2.5s linear;
}

@keyframes leverPush {
  0% {
    transform: translateY(0);
  }
  8% {
    transform: translateY(50%);
  }
  90% {
    transform: translateY(50%);
  }
  100% {
    transform: translateY(0);
  }
}

.coffee--active {
  animation: coffeeStream 2.4s 0.1s ease-out forwards;
}

@keyframes coffeeStream {
  0% {
    transform: translateY(0);
  }
  5% {
    transform: translateY(50%);
  }
  95% {
    transform: translateY(50%);
  }
  100% {
    transform: translateY(150%);
  }
}

Apply the active class on click and remove it after the animation finishes.

const lever = document.querySelector(".lever");
const coffee = document.querySelector(".coffee");

lever.addEventListener("click", function () {
  if (lever.classList.contains("lever--active")) {
    return;
  }

  lever.classList.add("lever--active");
  coffee.classList.add("coffee--active");

  setTimeout(function () {
    lever.classList.remove("lever--active");
    coffee.classList.remove("coffee--active")
  }, 2500);
});

The full demo is below; try extending it by animating the speech bubble or blinking the machine’s lights during the pour.

See the Pen [Smashing cat interaction [forked]](https://codepen.io/smashingmag/pen/gOjzMap) by Adrian Bece.

See the Pen Smashing cat interaction [forked] by Adrian Bece.

Wrapping Up

The goal of these examples is to show that expressive SVG animation doesn’t require complex tooling—just a working knowledge of CSS transforms, keyframes, and a few helper properties like transform-box. With this workflow, you can add motion and interactivity to almost any existing SVG, adapting it to your project’s needs.

If you build something with these techniques, feel free to share it on Twitter.

Further Reading

Smashing Editorial