agentsclimarketplace

Animated svg smil

Skill duckhoa-uit/skills/skills/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

Install
npx -y skills add duckhoa-uit/skills --skill animated-svg-smil

Assembled 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 like begin="myIcon.mouseenter" find their target
  • The id must 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 .svg first, then .tsx if 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 linear
  • keySplines — a custom ease-out curve (0.25 0.1 0.25 1), deliberately stronger than the CSS default ease-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 1
  • strokeDasharray="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 conceptAnimation approach
Copy / duplicateTranslate layers apart (one up-left, one down-right)
Expand / fullscreenMove corners outward from center
Settings / gearRotate the gear element
Refresh / syncRotate the whole SVG
Delete / trashLift the lid upward
Bell / notificationRock/swing the whole shape
Dollar / currencyDraw the path progressively
Click / cursorMulti-step: cursor moves, then burst lines radiate
Stack / layersSpread layers apart along a diagonal
Vote / thumbTilt/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:

  1. Convert all commands to cubic beziers (C). Every SVG command can be expressed as a C:
    • L x,y → degenerate bezier C prevX,prevY x,y x,y
    • Q cx,cy x,y → equivalent cubic (quadratic-to-cubic conversion)
    • H, V, S, T → expand to full C equivalents
  2. Add extra points to the simpler path by subdividing segments so both paths have equal point counts.
  3. 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 typeEasingWhy
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-outOrganic motion naturally slows at direction changes. Use keySplines="0.42 0 0.58 1".
Path morphing loopscalcMode="spline" with ease-in-outShape 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–20s depending 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

  1. Pick or receive the base SVG paths (Lucide or custom)
  2. Identify which parts should animate and what motion fits the icon's meaning
  3. Wrap the component in a <div> with a unique id
  4. Add paired <animateTransform> or <animate> elements (enter + leave)
  5. Use {...commonAnimationProps} for consistent easing
  6. Give animation elements ids if you need sequencing
  7. Test that mouseleave properly resets all animated properties to their original values
  8. For path morphing: verify both paths have identical command structures
  9. For continuous animations: ensure first value equals last value in values list
  10. For pages with many animations: consider pausing offscreen SVGs for performance

Keep looking

Skills are one crate of 627,801. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.