GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

9 SVG Icon Animation CSS — One Line, Copy-Paste

These nine svg icon animation css patterns swap a static interface icon for a live one, redrawing a bell, a refresh arrow, or a play button as an inline SVG

Auto-plays · click a tile to jump to its section · all nine in one zip

The order below follows the role each icon plays on screen. It opens with two icons that just sit still until they catch the eye — a badge star and a logo mark (01, 02). It moves through five icon buttons that switch state on a click (03 through 07), and closes with two icons that quietly support a line of text (08, 09). All nine touch only transform and opacity, and the three built by drawing a line — 01, 02, and 09 — also animate stroke-dasharray and stroke-dashoffset. Those two live in the paint step rather than layout, so unlike a property such as width they never force a reflow; the MDN stroke-dasharray reference covers how a browser treats them. An icon here carries the state on its own; the same state reactions wired into a form field instead are collected in 9 CSS input focus animations. Turning the same shapes on the z axis, so depth rather than a stroke does the work, is what the 9 CSS 3D cube effects are for.

01Stroke draw

Drawing the outline with dashoffset

A star icon's path, given pathLength="100", animates stroke-dashoffset from 100 down to 0 so the outline appears to draw itself, holds a beat, then unwinds the other way back to hidden.

Stroke draw
Open in a new tab

01 · Stroke draw — Measured area 0.287% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

stroke-dasharray 100600ms빠르게 300ms

Beside visible text, aria-hidden

It sits beside visible text such as "New feature," so the SVG only needs aria-hidden="true" and never competes with the label for a screen reader's attention.

.badge__star {
  fill: none;
  stroke: $color;
  stroke-width: 1.6;
  stroke-dasharray: 100;             // pathLength="100" lets dasharray act like a percent
  animation: draw $duration $easing infinite;
}

@keyframes draw {
  0%   { stroke-dashoffset: 100; }   // hidden
  55%  { stroke-dashoffset: 0; }     // fully drawn
  80%  { stroke-dashoffset: 0; }     // brief hold
  100% { stroke-dashoffset: -100; }  // erases toward the other end
}

@media (prefers-reduced-motion: reduce) {
  .badge__star { animation: none; stroke-dashoffset: 0; }
}

02Logo reveal

Two copies of the same mark

Two copies of the same diamond mark sit stacked — one stroke-only, one filled — and the stroke draws itself first before the fill fades in over it via opacity, then both reset.

Logo reveal
Open in a new tab

02 · Logo reveal — Measured area 0.881% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

stroke → fill 2단계600ms라인만 남기기

Where a retracing logo fits

This svg logo animation css suits a hero section where a hand-drawn brand mark keeps retracing itself on a loop, rather than sitting there static like a plain logo.

.mark__line {
  fill: none;
  stroke: $color;
  stroke-width: 1.6;
  stroke-dasharray: 100;
  animation: line-draw $duration $easing infinite;
}
.mark__fill {
  fill: $color;
  opacity: 0;
  animation: fill-in $duration $easing infinite;
}

@keyframes line-draw {
  0%   { stroke-dashoffset: 100; opacity: 1; }
  55%  { stroke-dashoffset: 0;   opacity: 1; }
  85%  { opacity: 0; }
  100% { stroke-dashoffset: 0;   opacity: 0; }
}
@keyframes fill-in {
  0%, 55%  { opacity: 0; }
  75%, 85% { opacity: 1; }
}

03Icon morph

Two glyphs in one grid cell

A play triangle and a pause glyph sit in the same grid cell and cross over on click, fading opacity while scaling between .85 and 1 rather than one shape rebuilding into the other.

Icon morph
Open in a new tab

03 · Icon morph — Measured area 0.135% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

opacity 크로스페이드220msscale .85→1

Swapping the title text

Because the button carries no other label, an <title> inside each SVG is what a screen reader actually reads, so the markup swaps that title text — "Play" to "Pause" — the instant aria-pressed flips.

.ic {
  grid-area: 1 / 1;
  fill: currentColor;
  transition: opacity $duration $easing, transform $duration $easing;
  transform-box: fill-box;
  transform-origin: 50% 50%;
}
.ic--play  { opacity: 1; }
.ic--pause { opacity: 0; transform: scale(.85); }

.iconbtn[aria-pressed="true"] .ic--play  { opacity: 0; transform: scale(.85); }
.iconbtn[aria-pressed="true"] .ic--pause { opacity: 1; transform: scale(1); }

@media (prefers-reduced-motion: reduce) {
  .ic { transition: none; }
}

04Bell shake

Rocking around the mount point

A bell rocks left and right around its mount point while a badge dot above it swells and settles on the same beat, both pivoting on transform-box: fill-box rather than the SVG viewport's corner.

Bell shake
Open in a new tab

04 · Bell shake — Measured area 0.233% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

transform-box: fill-box진폭 8deg배지 pulse

What actually reaches a screen reader

A bare aria-label="3 notifications" on the button is what actually tells a screen reader anything happened, since the shaking svg itself stays aria-hidden.

// transform-box: fill-box makes the bell's own bounding box the rotation
// axis, instead of the SVG viewport's (0,0)-(24,24) corner
.bell {
  transform-box: fill-box;
  transform-origin: 50% 0%;   // the bell's mount point, top center
}
.badge-dot {
  fill: $color;
  transform-box: fill-box;
  transform-origin: 50% 50%;
}

.iconbtn.is-demo .bell      { animation: shake $dur-loop $easing infinite; }
.iconbtn.is-demo .badge-dot { animation: pulse $dur-loop $easing infinite; }

@keyframes shake {
  0%, 30%, 100% { transform: rotate(0); }
  4%  { transform: rotate(8deg); }
  8%  { transform: rotate(-8deg); }
  16% { transform: rotate(-6deg); }
  24% { transform: rotate(-3deg); }
}

05Refresh spin

Why a css icon spin animation needs fill-box

The two-arrow refresh shape isn't centered in its 24x24 viewBox, so rotating the <g> on transform-box: fill-box pivots it around its own bounding box rather than the viewport corner, letting one click spin it a clean 360 degrees.

Refresh spin
Open in a new tab

05 · Refresh spin — Measured area 0.43% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

transform-box: fill-box360deg 600ms연속 회전(.is-demo)

Naming the button

The button leans on aria-label="Refresh" for its name while the arrows stay aria-hidden.

// the two-arrow shape isn't centered in the 24x24 viewBox, so
// transform-box: fill-box pivots on the shape's own bounding box instead
.refresh {
  transform-box: fill-box;
  transform-origin: 50% 50%;
}
.iconbtn.is-spinning .refresh { animation: spin-once $duration $easing; }
.iconbtn.is-demo .refresh     { animation: spin-loop $dur-loop $easing infinite; }

@keyframes spin-once { to { transform: rotate(360deg); } }
@keyframes spin-loop {
  0%, 10% { transform: rotate(0); }
  55%     { transform: rotate(360deg); }
  100%    { transform: rotate(360deg); }
}

06Menu icon morph

Cross-dissolve, no rotation

This css menu icon animation cross-dissolves a three-dot glyph and an X in the same cell on opacity alone when the button opens or closes — no rotation, unlike a hamburger built from three bars spinning into an X.

Menu icon morph
Open in a new tab

06 · Menu icon morph — Measured area 0.111% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

점 3개 → Xopacity 크로스페이드220ms

Where the real name lives

<title> again carries the real name here, updated to "Open menu" or "Close menu" and read through the button's aria-labelledby.

.ic { grid-area: 1 / 1; fill: currentColor; transition: opacity $duration $easing; }
.ic--dots { opacity: 1; }
.ic--x    { opacity: 0; }

.iconbtn[aria-expanded="true"] .ic--dots { opacity: 0; }
.iconbtn[aria-expanded="true"] .ic--x    { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .ic { transition: none; }
}

07Heart pulse

Two hearts, one overshoot

An outlined heart and a filled one overlap, and a tap reveals the filled version while both briefly overshoot to 1.3x scale before settling back to 1, all timed off the same click.

Heart pulse
Open in a new tab

07 · Heart pulse — Measured area 0.338% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

scale 1→1.3→1220msfill 크로스페이드

A label that never depends on state

aria-pressed toggles on the button alongside a fixed aria-label="Like", so the accessible name never depends on which heart happens to be visible.

.ic {
  grid-area: 1 / 1;
  transition: opacity $duration $easing, transform $duration $easing;
  transform-box: fill-box;
  transform-origin: 50% 50%;
}
.ic--outline { opacity: 1; }
.ic--fill    { fill: $color; opacity: 0; }

.iconbtn[aria-pressed="true"] .ic--outline { opacity: 0; }
.iconbtn[aria-pressed="true"] .ic--fill    { opacity: 1; transform: scale(1); }

@keyframes fill-loop {
  30%      { transform: scale(1.3); }
  40%, 60% { opacity: 1; }
}

08Arrow move

An 8px bob in place

A single downward arrow bobs up and down 8px in place on a slow two-second loop, nudging a "See more" label without ever changing shape.

Arrow move
Open in a new tab

08 · Arrow move — Measured area 0.108% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateY 8px2s 루프ease-spring

No name of its own

It sits right next to that visible label, so the svg stays aria-hidden and carries no name of its own.

$duration: $dur-loop;   // 2s
$easing: $ease-spring;

.cue__arrow { animation: bob $duration $easing infinite; }

@keyframes bob {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY($sp-2); }   // 8px
}

@media (prefers-reduced-motion: reduce) {
  .cue__arrow { animation: none; }
}

09Check draw

A polyline drawn left to right

A polyline checkmark, also carrying pathLength="100", draws left to right on stroke-dashoffset the same way item 01's star does, then unwinds after a short hold.

Check draw
Open in a new tab

09 · Check draw — Measured area 0.34% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

stroke-dashoffsetpolyline 체크300ms

Confirming what text already said

It confirms something already announced in visible text — "Saved" — so it too stays purely aria-hidden.

.confirm__check {
  stroke-dasharray: 100;               // pathLength="100"
  animation: draw $duration $easing infinite;
}

@keyframes draw {
  0%   { stroke-dashoffset: 100; }
  55%  { stroke-dashoffset: 0; }
  80%  { stroke-dashoffset: 0; }
  100% { stroke-dashoffset: -100; }
}

@media (prefers-reduced-motion: reduce) {
  .confirm__check { animation: none; stroke-dashoffset: 0; }
}

Where does the rotation axis break?

The pivot is the viewport, not the shape

The trap that shows up most often here is the rotation axis. Rotating a <g> directly spins it around the SVG viewport's own (0,0)-(24,24) corner rather than the shape's own center, which is exactly what threw off early passes of item 05's refresh arrows and item 04's bell. Both swung off to one side instead of turning in place. Adding transform-box: fill-box fixes it by making the shape's own bounding box the pivot instead of the viewport. Support reaches back far enough to ignore as a concern: Chrome 64, Edge 79, Firefox 55, and Safari 11 (including iOS) all ship it, per caniuse's transform-box table. Two other items needed a second pass after their first cut measured too weak on screen — item 04's shake originally covered just 15% of its loop and barely registered, and item 09's checkmark started at 26px, small enough that almost no pixels changed at all. Both got widened and enlarged until the motion actually showed up in frame-by-frame capture. Those corrected values are exactly what ships in the archive, unlocked with datzbfgj typed exactly as it reads on this line, no spaces added anywhere.

Why no text glyphs

A second trap worth naming: none of these nine are drawn as text glyphs like ✓ or →. A character like that renders however the visitor's operating system font happens to draw it, which sometimes means it doesn't render at all. Every icon above is an SVG path instead, so its shape never depends on which font is installed.

Stroke widths here were picked assuming the icon sits on a flat card. Drop the same path onto a surface puffed out with paired inner and outer shadows and the contrast reads differently, which is the kind of ground the nine claymorphism landing sections are built to produce.

Accessibility

Naming icon-only buttons

Five of the nine — 03, 04, 05, 06, and 07 — are icon-only buttons, so without a spoken name a screen reader only announces "button" and gives no hint what it does. 03 and 06 update an <title> element inside the SVG and point the button at it with aria-labelledby, swapping that title text the moment the state flips:

<button aria-labelledby="t3">
  <svg><title id="t3">Play</title><path d="M8 5l11 7-11 7z"/></svg>
</button>

Decorative icons stay hidden

04, 05, and 07 skip the title and label the button directly with a fixed aria-label instead — 07 also toggles the boolean aria-pressed attribute, but the label text itself never changes. The other four — 01, 02, 08, and 09 — sit next to visible text that already carries the meaning, so their SVG only needs aria-hidden="true":

<svg aria-hidden="true"><path class="badge__star" d="M12 2l2.9 6.9L22 9.3z"/></svg> New feature

Reduced motion keeps the end state

Under prefers-reduced-motion: reduce, all nine turn their animation off and land on the finished shape — a fully drawn check, a filled heart — rather than erasing the end state along with the motion. Dropping both would erase information such as whether a save actually went through. Browse the rest of this line of demos from the CSS category hub, or see what this site does and doesn't sell on the about page.

FAQ

Can a css icon pulse animation run as a webfont instead of inline SVG?

They can, but a webfont glyph can flash unstyled or fail to render at all before its file finishes loading. Inline SVG never waits on a file request, so every icon in the archive shows up the instant the page — or the opened zip — paints.

What actually breaks without transform-box: fill-box?

rotate()'s pivot falls back to the SVG viewport's top-left corner, so a shape sitting off-center in its 24x24 viewBox swings around a point nowhere near itself. Item 05 is the clearest case: strip out fill-box and the whole refresh arrow appears to leap sideways as it spins instead of turning in place. If the only shape you need is the one from item 08, nine variants of it are gathered in arrow icon CSS animations.

How do the five icon buttons track their state in React?

The archive's react/ folder keeps one useState boolean per button — playing, open, or liked — and swaps opacity and the aria attributes through a className branch instead of toggling a class name directly.

Enter the archive password

The password is inside this article. You will find it as you read.