Animated svg smil
Create animated SVG icons using pure SMIL (Synchronized Multimedia Integration Language) animations — no JavaScript animation libraries needed. Use this skill whenever the user wants to animate SVG icons, create hover-animated icons, build an animated icon component, add SMIL animations to SVGs, create interactive SVG elements with mouseenter/mouseleave triggers, or convert static SVG icons into animated ones. Also use when the user mentions 'animated icons', 'SVG animation without JS', 'pure SVG animation', 'SMIL animation', 'hover icon animation', 'animateTransform', or wants icon animations that work without framer-motion or other JS animation libraries.From its SKILL.md
npx -y skills add duckhoa-uit/skills --skill animated-svg-smilAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
22.0 KB, as published. Nobody here has run it
Animated SVG Icons with SMIL
Build animated SVG icon components using pure SMIL animations — declarative, performant, and zero-JS-dependency animations that live directly inside the SVG markup.
Why SMIL over JavaScript animation libraries
SMIL animations are native to SVG. They run on the browser's rendering engine without JavaScript overhead, which means smoother performance and smaller bundle size. They're ideal for icon-sized animations triggered by user interaction (hover, click) because:
- No runtime dependency (no framer-motion, GSAP, etc.)
- Animations are declarative — they live in the markup alongside the SVG paths
- Browser handles interpolation natively, often GPU-accelerated
- Simpler mental model: each element owns its own animation
The tradeoff is that SMIL is less flexible than JS for complex orchestration — but for icon animations, it's the sweet spot.
Output formats
Since SMIL animations live inside the SVG markup with zero JS dependencies, the primary output should be a standalone .svg file. This works everywhere — <img> tags, <object> embeds, direct inline HTML, any framework. Optionally, also produce a .tsx React component wrapper when the project uses React/Next.js.
Important: SMIL animations do NOT run when an SVG is loaded via <img> or CSS background-image (browser security restriction). They work in inline SVG and <object>/<embed>. For hover-triggered animations, the SVG must be inline in the DOM.
Standalone SVG format (primary)
For hover-triggered animations, the SVG element itself gets an id used as the SMIL trigger target:
<svg
id="myIcon"
xmlns="http://www.w3.org/2000/svg"
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2">
<animateTransform
attributeName="transform"
type="translate"
to="-3 -3"
dur="0.3s"
begin="myIcon.mouseenter"
fill="freeze"
calcMode="spline"
keySplines="0.25 0.1 0.25 1;"
keyTimes="0; 1"
/>
<animateTransform
attributeName="transform"
type="translate"
to="0 0"
dur="0.3s"
begin="myIcon.mouseleave"
fill="freeze"
calcMode="spline"
keySplines="0.25 0.1 0.25 1;"
keyTimes="0; 1"
/>
</rect>
<!-- more paths... -->
</svg>
Note: in standalone SVG, use stroke-width (kebab-case), not strokeWidth (camelCase). Kebab-case is the SVG spec; camelCase is JSX.
React/TSX component wrapper (optional)
When wrapping for React, the wrapper <div> gets the id instead (so you can style the hover area), and SVG attributes use camelCase:
"use client";
const MyIcon = () => {
return (
<div
id="myIcon"
className="cursor-pointer select-none p-2 hover:bg-accent rounded-md transition-colors duration-200 flex items-center justify-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{/* SVG paths with embedded SMIL animation elements */}
</svg>
</div>
);
};
export { MyIcon };
Key structural rules
- The trigger target (
id) goes on either the<svg>element (standalone) or a wrapper<div>(React) — this is how SMIL events likebegin="myIcon.mouseenter"find their target - The
idmust be unique on the page and use camelCase - SVG uses
viewBox="0 0 24 24"(Lucide icon standard) - For React/Next.js, add
"use client"directive - Always output
.svgfirst, then.tsxif the project uses React
Shared easing utility
Define this once and spread it into animation elements for consistent, natural-feeling easing:
export const commonAnimationProps = {
fill: "freeze",
calcMode: "spline",
keySplines: "0.25 0.1 0.25 1;",
keyTimes: "0; 1",
};
fill="freeze"— holds the end state after animation completes (without this, the element snaps back)calcMode="spline"— enables bezier easing instead of linearkeySplines— a custom ease-out curve (0.25 0.1 0.25 1), deliberately stronger than the CSS defaultease-out(0.0 0.0 0.58 1.0). Ease-out is the right choice here because these animations are entering a new state on hover — they should start fast (giving instant visual feedback the moment the cursor arrives) and decelerate naturally into their final position. An ease-in curve would delay the initial movement, making icons feel sluggish at the exact moment the user is watching most closely.keyTimes— normalized time points matching the keySplines
Spread it with {...commonAnimationProps} on <animate> and <animateTransform> elements.
Animation patterns
Pattern 1: Translate on hover
The most common pattern — shift an element on mouseenter, return on mouseleave.
<path d="M21 7h-3a2 2 0 0 1-2-2V2">
<animateTransform
attributeName="transform"
type="translate"
to="-4 4"
dur="0.25s"
begin="myIcon.mouseenter"
{...commonAnimationProps}
/>
<animateTransform
attributeName="transform"
type="translate"
to="0 0"
dur="0.25s"
begin="myIcon.mouseleave"
{...commonAnimationProps}
/>
</path>
Each animated element needs two <animateTransform> children — one for enter, one for leave. The to="0 0" on leave resets the element to its original position.
Use this for: separating stacked elements, expanding arrows outward, nudging pieces apart.
Multi-element example (expand icon — 4 corners moving outward):
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8">
{/* Bottom-right corner moves down-right */}
<animateTransform ... to="2 2" begin="expand.mouseenter" />
<animateTransform ... to="0 0" begin="expand.mouseleave" />
</path>
<path d="M3 16.2V21m0 0h4.8M3 21l6-6">
{/* Bottom-left corner moves down-left */}
<animateTransform ... to="-2 2" begin="expand.mouseenter" />
<animateTransform ... to="0 0" begin="expand.mouseleave" />
</path>
Pattern 2: Rotate on hover
Rotate an element (or group of elements) around a specified center point.
<g transform="rotate(0 18 18)">
{/* All the gear spoke paths */}
<path d="m15.2 16.9-.9-.4" />
<path d="m15.2 19.1-.9.4" />
{/* ... more paths ... */}
<circle cx="18" cy="18" r="3" />
<animateTransform
attributeName="transform"
type="rotate"
from="0 18 18"
to="360 18 18"
dur="1.4s"
begin="calendarCog.mouseenter"
values="0 18 18; 360 18 18;"
{...commonAnimationProps}
/>
<animateTransform
attributeName="transform"
type="rotate"
from="360 18 18"
to="0 18 18"
dur="1.4s"
begin="calendarCog.mouseleave"
values="360 18 18; 0 18 18;"
{...commonAnimationProps}
/>
</g>
For rotate, the from/to values are "angle centerX centerY". Wrap multiple paths in a <g> to rotate them as a unit.
Use the element's visual center as the rotation origin — for a gear at (18, 18), rotate around 18 18.
For simpler rotations (whole SVG tilt), apply directly to the <svg> element's transform:
<svg ... transform="rotate(0 12 12); translate(0 0)">
<animateTransform type="rotate" to="-12 12 12" begin="id.mouseenter" />
<animateTransform type="rotate" to="0 12 12" begin="id.mouseleave" />
</svg>
Pattern 3: Path drawing (stroke-dashoffset)
Animate a path being "drawn" progressively using stroke-dashoffset.
Setup the path element:
<path
d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"
opacity="1"
pathLength="1"
strokeDasharray="1"
>
pathLength="1"normalizes the path length so you can animate from 0 to 1strokeDasharray="1"makes the entire path a single dash
Then animate stroke-dashoffset:
<animate
id="curve"
attributeName="stroke-dashoffset"
from="1"
to="0"
dur="0.6s"
fill="freeze"
begin="myIcon.mouseenter"
/>
from="1"= fully hidden,to="0"= fully drawn- Use
from="-1"to draw in reverse direction
Pattern 4: Chained/sequenced animations
Chain animations so one starts after another finishes — use the id attribute on animations and reference them with begin="animId.endEvent".
{/* Step 1: Main animation */}
<animate
id="curve"
attributeName="stroke-dashoffset"
from="1" to="0"
dur="0.6s"
fill="freeze"
begin="myIcon.mouseenter"
/>
{/* Step 2: Starts after step 1 ends */}
<animate
id="path"
attributeName="stroke-dashoffset"
from="-1" to="0"
dur="0.4s"
begin="curve.endEvent"
{...commonAnimationProps}
/>
You can also use time offsets: begin="cursor.endEvent+0.1s" to add a delay after the previous animation.
For complex sequences, you can hide elements during transitions:
{/* Hide immediately when sequence starts */}
<animate attributeName="opacity" to="0" dur="0.01s" begin="curve.begin" fill="freeze" />
{/* Show when ready */}
<animate attributeName="opacity" from="0" to="1" dur="0.1s" begin="curve.endEvent" fill="freeze" />
Pattern 5: Opacity with easing
For smooth fade in/out with custom easing:
<animate
attributeName="opacity"
from="0"
to="1"
dur="0.2s"
begin="cursor.endEvent"
fill="freeze"
calcMode="spline"
keySplines="0.17 0.67 0.29 0.99;"
keyTimes="0; 1"
/>
Pattern 6: Multi-keyframe animations
For complex motion paths with multiple steps, use values with matching keyTimes and keySplines:
<animateTransform
attributeName="transform"
type="translate"
dur="1s"
begin="myIcon.mouseenter"
fill="freeze"
calcMode="spline"
keySplines="0.25 0.1 0.25 1; 0.25 0.1 0.25 1; 0.25 0.1 0.25 1;"
keyTimes="0; 0.33; 0.67; 1"
values="0 0; 0 -4; -3 0; 0 0"
/>
Each value in values is a keyframe. The number of keySplines entries = number of values - 1. Each keyTimes entry corresponds to a value.
Helper function pattern for repeated animations
When multiple elements share the same animation logic (e.g., click-burst lines radiating outward), extract a helper:
const getLineAnimateElements = (
id: string,
translate: { x: number; y: number },
) => (
<>
<animate attributeName="opacity" to="0" dur="0.01s" begin="cursor.begin" fill="freeze" />
<animate attributeName="opacity" from="0" to="1" dur="0.2s" begin="cursor.endEvent" fill="freeze" />
<animateTransform
id={`translateOut_${id}`}
attributeName="transform"
type="translate"
to={`${translate.x} ${translate.y}`}
dur="0.2s"
begin="cursor.endEvent"
{...commonAnimationProps}
/>
{/* fade out after translate */}
<animate
id={`hideAfterTranslateOut_${id}`}
attributeName="opacity"
from="1" to="0"
dur="0.2s"
begin={`translateOut_${id}.begin+0.1s`}
fill="freeze"
/>
{/* fade back in after delay */}
<animate
attributeName="opacity"
from="0" to="1"
dur="0.2s"
begin={`hideAfterTranslateOut_${id}.endEvent+0.6s`}
fill="freeze"
/>
</>
);
Then use it on each element with unique IDs and directional vectors:
<path d="M14 4.1 12 6" opacity="1">
{getLineAnimateElements("1", { x: 1, y: -1 })}
</path>
<path d="m5.1 8-2.9-.8" opacity="1">
{getLineAnimateElements("2", { x: -1, y: 0 })}
</path>
Design guidelines
Choosing the right animation for an icon
The animation should make the icon's verb visible — copy separates because copying creates a duplicate, expand pushes outward because expanding claims space, a gear rotates because that's what gears do. Animate the part that reinforces the icon's action:
| Icon concept | Animation approach |
|---|---|
| Copy / duplicate | Translate layers apart (one up-left, one down-right) |
| Expand / fullscreen | Move corners outward from center |
| Settings / gear | Rotate the gear element |
| Refresh / sync | Rotate the whole SVG |
| Delete / trash | Lift the lid upward |
| Bell / notification | Rock/swing the whole shape |
| Dollar / currency | Draw the path progressively |
| Click / cursor | Multi-step: cursor moves, then burst lines radiate |
| Stack / layers | Spread layers apart along a diagonal |
| Vote / thumb | Tilt/rotate the whole shape |
Timing guidelines
- Simple hover translations:
0.2s–0.4s— fast enough to feel responsive - Rotations:
0.4s–1.4s— depends on rotation amount (360deg needs more time) - Path drawing:
0.4s–0.8s— slower feels more deliberate - Opacity fades:
0.1s–0.2s— quick, don't draw attention to the fade itself - Reset (mouseleave): same duration as enter, or slightly faster. Exits should generally feel snappier than entries — the system is responding, not the user deciding. A mouseleave reset at 0.2s feels more polished than matching a 0.4s enter duration.
Animation restraint
Icon hover animations fall in the "tens of times per day" frequency range. Users hovering over icon toolbars will see these animations repeatedly. Keep them subtle and fast — the animation should reinforce the icon's meaning without becoming a distraction. If you find yourself adding bounce, overshoot, or durations over 0.5s for a simple hover translation, dial it back.
Accessibility
SMIL animations do not consistently respect prefers-reduced-motion across all browsers. If your project needs to support motion-sensitive users, consider wrapping animated icons in a component that checks the preference and renders static SVGs as a fallback, or use a CSS @media (prefers-reduced-motion: reduce) rule to hide the wrapper div's hover behavior via pointer-events: none on the animated elements.
SVG source
Use Lucide icons as the base SVG paths. They use a consistent viewBox="0 0 24 24", strokeWidth="2", strokeLinecap="round", strokeLinejoin="round" convention.
Advanced: Path morphing (animate attributeName="d")
Path morphing animates the shape of a path itself by interpolating between two d attribute values. This is useful for morphing between two states of an illustration, icon shape transitions (hamburger → X, play → pause), or organic shape changes.
Structural match requirement
For smooth interpolation, both paths must be structurally identical — same number of commands, same command types in the same order, same number of coordinate values per command. If they don't match, the browser does a discrete jump (instant swap) instead of a smooth morph.
{/* Works — both paths use the same command structure */}
<path d="M10,10 C20,20 40,20 50,10">
<animate attributeName="d"
values="M10,10 C20,20 40,20 50,10; M10,80 C20,50 40,50 50,80; M10,10 C20,20 40,20 50,10"
dur="2s" repeatCount="indefinite"
calcMode="spline"
keySplines="0.42 0 0.58 1; 0.42 0 0.58 1"
keyTimes="0; 0.5; 1" />
</path>
{/* Broken — L vs C mismatch, will snap instead of morph */}
<animate attributeName="d" from="M10,10 L50,10" to="M10,10 C20,20 40,20 50,10" />
Normalizing paths for compatibility
When your before/after paths come from different sources (Figma exports, different icon sets, hand-drawn), they often have incompatible structures. To fix this:
- Convert all commands to cubic beziers (
C). Every SVG command can be expressed as aC:L x,y→ degenerate bezierC prevX,prevY x,y x,yQ cx,cy x,y→ equivalent cubic (quadratic-to-cubic conversion)H,V,S,T→ expand to fullCequivalents
- Add extra points to the simpler path by subdividing segments so both paths have equal point counts.
- Design with morphing in mind — the most reliable approach is to design both states on the same canvas with the same number of anchor points.
If normalization isn't feasible, use an opacity crossfade instead of a morph:
<path d="M...shape1" opacity="1">
<animate attributeName="opacity" values="1; 0" dur="0.3s" fill="freeze" begin="trigger" />
</path>
<path d="M...shape2" opacity="0">
<animate attributeName="opacity" values="0; 1" dur="0.3s" fill="freeze" begin="trigger" />
</path>
Easing for morphs
Use ease-in-out (keySplines="0.42 0 0.58 1") for path morphing. Both halves of a shape transition should feel organic — unlike hover enter/exit where ease-out is preferred, morphing is a symmetric transformation where acceleration and deceleration both matter.
Performance
Path morphing is the most expensive SMIL animation type — it cannot be GPU-composited and requires re-rasterization every frame. Guidelines:
- Keep morphing paths under ~50 segments for reliable 60fps on mobile
- Prefer
<animateTransform>(translate, rotate, scale) over path morphing when possible — transforms are GPU-friendly - Avoid combining path morphing with SVG filters or clip-paths
- Round path coordinates to 1–2 decimal places to reduce parse overhead
Advanced: Continuous/looping animations
For ambient animations that run indefinitely (spinning gears, pulsing elements, floating effects), use repeatCount="indefinite".
Seamless looping
The most important rule: the last value must equal the first value in your values list. Otherwise you get a visible jump at the loop boundary.
{/* Seamless — first and last values match */}
<animateTransform attributeName="transform" type="translate"
values="0 0; 0 -3; 0 0"
dur="4s" repeatCount="indefinite"
calcMode="spline"
keySplines="0.42 0 0.58 1; 0.42 0 0.58 1"
keyTimes="0; 0.5; 1" />
{/* Broken — jumps from -3 back to 0 on each loop restart */}
<animateTransform attributeName="transform" type="translate"
from="0 0" to="0 -3"
dur="4s" repeatCount="indefinite" />
Exception: Rotations from 0° to 360° are inherently seamless since they're the same position.
Easing: linear vs spline
| Animation type | Easing | Why |
|---|---|---|
| Continuous rotation (spinners, gears) | calcMode="linear" | Constant speed feels natural for mechanical rotation. Easing causes visible acceleration/deceleration at each loop boundary. |
| Oscillation (breathing, bobbing, pulsing) | calcMode="spline" with ease-in-out | Organic motion naturally slows at direction changes. Use keySplines="0.42 0 0.58 1". |
| Path morphing loops | calcMode="spline" with ease-in-out | Shape changes need smooth acceleration on both ends. |
{/* Constant-speed gear rotation */}
<animateTransform attributeName="transform" type="rotate"
from="0 18 18" to="360 18 18"
dur="8s" repeatCount="indefinite" />
{/* Breathing/pulsing scale */}
<animateTransform attributeName="transform" type="scale"
values="1; 1.05; 1"
keyTimes="0; 0.5; 1"
keySplines="0.42 0 0.58 1; 0.42 0 0.58 1"
calcMode="spline"
dur="3s" repeatCount="indefinite" />
Timing for continuous animations
- Rotation:
4s–20sdepending on desired speed. Faster for loading spinners (~1–2s), slower for decorative gears (~8–20s). - Oscillation/bobbing:
2s–6s— slow enough to feel ambient, not distracting. - Path morphing loops:
4s–10s— shape changes need time to read.
Pausing offscreen (performance)
Continuous animations consume resources even when scrolled out of view. For pages with many animated SVGs, pause them when not visible:
// Using IntersectionObserver + SVG DOM API
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const svg = entry.target as SVGSVGElement;
if (entry.isIntersecting) {
svg.unpauseAnimations();
} else {
svg.pauseAnimations();
}
});
}, { threshold: 0 });
document.querySelectorAll('svg.animated').forEach(svg => observer.observe(svg));
An alternative pure-SMIL approach: set begin="indefinite" on animations and call beginElement() from JavaScript when the element becomes visible. See Zach Leatherman's approach for a detailed implementation.
Combining continuous + hover animations
Use additive="sum" to layer a hover effect on top of a continuous animation:
<g>
{/* Always running: slow idle rotation */}
<animateTransform attributeName="transform" type="rotate"
from="0 12 12" to="360 12 12"
dur="8s" repeatCount="indefinite" additive="sum" />
{/* On hover: additional fast spin, layered on top */}
<animateTransform attributeName="transform" type="rotate"
from="0 12 12" to="360 12 12"
dur="0.6s" repeatCount="1"
begin="myIcon.mouseenter" fill="remove" additive="sum" />
</g>
Key attributes for combining:
additive="sum"— animation adds to previous animations (not replaces)fill="remove"— effect disappears when animation ends, letting the continuous one show through
Checklist when creating an animated icon
- Pick or receive the base SVG paths (Lucide or custom)
- Identify which parts should animate and what motion fits the icon's meaning
- Wrap the component in a
<div>with a uniqueid - Add paired
<animateTransform>or<animate>elements (enter + leave) - Use
{...commonAnimationProps}for consistent easing - Give animation elements
ids if you need sequencing - Test that mouseleave properly resets all animated properties to their original values
- For path morphing: verify both paths have identical command structures
- For continuous animations: ensure first value equals last value in
valueslist - For pages with many animations: consider pausing offscreen SVGs for performance