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
- 01 Stroke draw
- 02 Logo reveal
- 03 Icon morph
- 04 Bell shake
- 05 Refresh spin
- 06 Menu icon morph
- 07 Heart pulse
- 08 Arrow move
- 09 Check draw
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.
01Stroke draw
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. 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 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. It 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
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. 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
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. 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
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. 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
A three-dot glyph and an X sit in the same cell and cross-dissolve on opacity alone when the button opens or closes — no rotation, unlike a hamburger built from three bars spinning into an X. <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
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. 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
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. 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 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. 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 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.
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.
Accessibility
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>
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
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 these icons 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.
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.