SVG animation in a single HTML file

Put the SVG inline in the page and it can move with a CSS keyframes rule, an SVG animate element or a few lines of JavaScript. No build step is needed for any of them.

SVG animation means making the shapes inside an <svg> move, fade, change colour or draw themselves. An HTML page has three ways to do it: CSS @keyframes, SVG's own animation elements (SMIL), and JavaScript, either element.animate() or a library such as GSAP.

All three work in a single HTML file. The one condition is that the SVG is inline: the <svg> code sits in the page, not behind an <img> tag.

Here is the CSS way. A star turns, a dot pulses and a line draws itself, with no JavaScript at all.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SVG animation with CSS</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #fff; }
  svg { display: block; width: 100%; max-width: 360px; height: auto; margin: 0 auto; }

  /* rotate around the shape's own centre, not the SVG's top-left corner */
  .sun {
    transform-box: fill-box;
    transform-origin: center;
    animation: spin 6s linear infinite;
  }
  .dot { animation: pulse 1.2s ease-in-out infinite alternate; }

  /* pathLength="1" makes the dash maths 1 instead of the real length */
  .line {
    stroke-dasharray: 1;
    stroke-dashoffset: 1;
    animation: draw 2.5s ease-out infinite;
  }

  @keyframes spin  { to { transform: rotate(360deg); } }
  @keyframes pulse { from { opacity: .3; } to { opacity: 1; } }
  @keyframes draw  { 70%, 100% { stroke-dashoffset: 0; } }

  /* people who ask for less motion get a still picture */
  @media (prefers-reduced-motion: reduce) {
    .sun, .dot, .line { animation: none; stroke-dashoffset: 0; }
  }
</style>
</head>
<body>
<svg viewBox="0 0 300 200" role="img" aria-label="A turning sun over a hill">
  <path class="line" pathLength="1" d="M10 170 Q 80 100 150 150 T 290 140"
        fill="none" stroke="#16a34a" stroke-width="6" stroke-linecap="round"/>
  <polygon class="sun" fill="#f59e0b"
           points="220,20 228,42 250,36 240,56 260,70 238,76 242,98 222,86 208,104 202,82 180,84 192,64 176,48 198,46 196,24 214,36"/>
  <circle class="dot" cx="60" cy="60" r="14" fill="#3b82f6"/>
</svg>
</body>
</html>
Three CSS animations on an inline SVG. Edit a duration and the example reruns.

The smallest working file

Copy this into an empty .html file and open it in a browser. The circle fades in and out forever.

<!doctype html>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
  .dot { animation: pulse 1s ease-in-out infinite alternate; }
  @keyframes pulse { from { opacity: .2; } to { opacity: 1; } }
</style>
<svg viewBox="0 0 100 100" width="200">
  <circle class="dot" cx="50" cy="50" r="30" fill="#3b82f6"/>
</svg>

The shape takes a class like any HTML element, and the keyframes rule is the same one you would write for a <div>. CSS keyframes covers the animation property itself.

Good properties to animate on SVG shapes are transform, opacity, fill, stroke and stroke-dashoffset. The last one gives the line-drawing effect in the first example.

Three ways to animate an SVG

The same square turns in each box below, written three different ways. Each one also has its own pause switch.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Three ways to animate SVG</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #fff; color: #1d2330; }
  .row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; max-width: 560px; margin: 0 auto; }
  figure { margin: 0; border: 1px solid #e1e4ea; border-radius: 10px; padding: 10px; text-align: center; }
  figcaption { font-size: 14px; font-weight: 600; margin-bottom: 6px; }
  svg { width: 100%; max-width: 120px; height: auto; }
  button { margin-top: 8px; font: inherit; font-size: 14px; padding: 6px 10px; border-radius: 6px; border: 1px solid #cbd2dc; background: #f5f7fa; cursor: pointer; }

  /* 1. CSS: a keyframes rule on an SVG element */
  #css-sq { transform-box: fill-box; transform-origin: center; animation: spin 3s linear infinite; }
  #css-sq.paused { animation-play-state: paused; }
  @keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="row">
  <figure>
    <figcaption>CSS</figcaption>
    <svg viewBox="0 0 100 100"><rect id="css-sq" x="30" y="30" width="40" height="40" rx="6" fill="#3b82f6"/></svg>
    <button id="css-btn">Pause</button>
  </figure>

  <figure>
    <figcaption>SMIL</figcaption>
    <!-- 2. SMIL: an <animateTransform> element inside the shape -->
    <svg id="smil-svg" viewBox="0 0 100 100">
      <rect x="30" y="30" width="40" height="40" rx="6" fill="#16a34a">
        <animateTransform attributeName="transform" type="rotate"
          from="0 50 50" to="360 50 50" dur="3s" repeatCount="indefinite"/>
      </rect>
    </svg>
    <button id="smil-btn">Pause</button>
  </figure>

  <figure>
    <figcaption>JavaScript</figcaption>
    <svg viewBox="0 0 100 100"><rect id="js-sq" x="30" y="30" width="40" height="40" rx="6" fill="#f59e0b"/></svg>
    <button id="js-btn">Pause</button>
  </figure>
</div>

<script>
  // 3. JavaScript: the Web Animations API, element.animate()
  const jsSq = document.getElementById('js-sq');
  jsSq.style.transformBox = 'fill-box';
  jsSq.style.transformOrigin = 'center';
  const anim = jsSq.animate(
    [{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }],
    { duration: 3000, iterations: Infinity }
  );

  // Each way has its own pause switch
  const toggle = (btn, isPaused, pause, play) => {
    btn.addEventListener('click', () => {
      isPaused() ? play() : pause();
      btn.textContent = isPaused() ? 'Play' : 'Pause';
    });
  };
  const cssSq = document.getElementById('css-sq');
  const smil = document.getElementById('smil-svg');
  toggle(document.getElementById('css-btn'),
    () => cssSq.classList.contains('paused'),
    () => cssSq.classList.add('paused'), () => cssSq.classList.remove('paused'));
  toggle(document.getElementById('smil-btn'),
    () => smil.animationsPaused(),
    () => smil.pauseAnimations(), () => smil.unpauseAnimations());
  toggle(document.getElementById('js-btn'),
    () => anim.playState === 'paused',
    () => anim.pause(), () => anim.play());
</script>
</body>
</html>
CSS, SMIL and the Web Animations API side by side. Press Pause on each.
Which way fits which job.
Which way fits which job.
  • CSS. A @keyframes rule plus the animation property. Pause by setting animation-play-state: paused.
  • SMIL. An <animate>, <animateTransform>, <animateMotion> or <set> element placed inside the shape. Pause every SMIL animation in an SVG with svg.pauseAnimations().
  • Web Animations API. element.animate() starts an animation from JavaScript and returns an Animation object with pause() and play().

SMIL can also start on a click without any script. begin="click" on the animate element waits for a click on the shape:

<circle cx="20" cy="50" r="15">
  <animate attributeName="cx" to="180" dur="0.4s"
           begin="click" fill="freeze"/>
</circle>

Why a shape spins around the corner

You add rotate(360deg) to a shape and it swings in a big circle across the SVG instead of turning in place.

Left: the default origin is 0 0 of the SVG. Right: fill-box moves it to the shape's own centre.
Left: the default origin is 0 0 of the SVG. Right: fill-box moves it to the shape's own centre.

On SVG shapes, transform-origin starts at 0 0 and transform-box starts at view-box, so the turn happens around the top-left of the SVG's coordinate system. Two lines fix it:

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

fill-box makes center and percentages refer to the shape's own bounding box. CSS transform-origin explains the property in more detail.

When the shape jumps to the top-left

SVG shapes often arrive with a transform attribute, such as transform="translate(160 110)". A CSS transform on the same element takes priority over that attribute and replaces it. The shape drops back to 0,0 the moment the animation starts.

The fix is to leave the attribute on an outer <g> and animate an inner one:

<g transform="translate(160 110)">
  <g class="pop"> ...shapes... </g>
</g>

The finished example below uses exactly this for the check-mark badge.

Inline SVG or an img tag

An SVG file shown with <img src="logo.svg"> is treated as a picture. In our Chromium test, CSS and SMIL animations inside the file still played, but a script inside it did not run.

The page's own CSS also could not reach the shapes inside.

An SVG in an img tag can play its own animation. Only an inline SVG can be driven by the page.
An SVG in an img tag can play its own animation. Only an inline SVG can be driven by the page.

So if the motion should react to buttons, scrolling or a library, paste the SVG into the page. SVG in HTML compares the ways to put an SVG on a page.

A finished example with GSAP

For a sequence of steps, a library saves arithmetic. GSAP is a JavaScript animation library that loads with one script tag. Its timeline plays tweens one after another, and the whole timeline can be restarted, reversed or scrubbed.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SVG animation with a GSAP timeline</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #fff; color: #1d2330; }
  .wrap { max-width: 420px; margin: 0 auto; }
  svg { display: block; width: 100%; height: auto; background: #f4f7fb; border-radius: 12px; }
  .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 12px; }
  button { font: inherit; font-size: 14px; padding: 7px 12px; border-radius: 6px; border: 1px solid #cbd2dc; background: #f5f7fa; cursor: pointer; }
  input[type=range] { flex: 1 1 140px; }
</style>
</head>
<body>
<div class="wrap">
  <svg viewBox="0 0 320 200" role="img" aria-label="A route drawn between two pins, then a check mark">
    <path id="route" d="M50 150 C 110 40, 200 190, 270 60" fill="none"
          stroke="#3b82f6" stroke-width="5" stroke-linecap="round" pathLength="1"
          stroke-dasharray="1" stroke-dashoffset="1"/>
    <circle class="pin" cx="50" cy="150" r="12" fill="#16a34a"/>
    <circle class="pin" cx="270" cy="60" r="12" fill="#f59e0b"/>
    <g transform="translate(160 110)">
      <!-- GSAP animates this inner group, so the outer transform stays intact -->
      <g id="badge">
        <circle r="26" fill="#16a34a"/>
        <path d="M-11 0 L-3 9 L12 -9" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
      </g>
    </g>
  </svg>
  <div class="controls">
    <button id="play">Play</button>
    <button id="reverse">Reverse</button>
    <input id="scrub" type="range" min="0" max="1" step="0.01" value="0" aria-label="Timeline position">
  </div>
</div>

<!-- 1. load GSAP (pinned version) before your own script -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>

<!-- 2. build one timeline: each step starts after the one before -->
<script>
  const scrub = document.getElementById('scrub');
  const tl = gsap.timeline({
    paused: true,
    onUpdate: () => { scrub.value = tl.progress(); }
  });

  tl.from('.pin', { scale: 0, transformOrigin: '50% 50%', duration: 0.4, stagger: 0.15 })
    .to('#route', { attr: { 'stroke-dashoffset': 0 }, duration: 1.2, ease: 'power1.inOut' })
    .from('#badge', { scale: 0, rotation: -90, transformOrigin: '50% 50%', duration: 0.5, ease: 'back.out(2)' });

  document.getElementById('play').addEventListener('click', () => tl.restart());
  document.getElementById('reverse').addEventListener('click', () => tl.reverse());
  scrub.addEventListener('input', () => tl.pause().progress(+scrub.value));

  tl.play();
</script>
</body>
</html>
Pins pop in, the route draws, then a badge appears. Drag the slider to scrub through the timeline.

The three steps are three chained calls:

const tl = gsap.timeline();
tl.from('.pin', { scale: 0, transformOrigin: '50% 50%', stagger: 0.15 })
  .to('#route', { attr: { 'stroke-dashoffset': 0 }, duration: 1.2 })
  .from('#badge', { scale: 0, rotation: -90, transformOrigin: '50% 50%' });
  • Load it first. The <script src> for GSAP must come before your own script. The other order throws gsap is not defined.
  • Pin the version. The example loads gsap@3.15.0 from jsDelivr, the latest version on npm on 1 October 2026.
  • Licence. The GSAP licensing page says GSAP is free for everyone, including commercial use, under its own standard licence.
  • Origin. GSAP's docs say its transformOrigin works on SVG elements consistently across browsers, so the fill-box lines are not needed here.

The line-drawing step uses pathLength="1" with stroke-dasharray="1", so the offset runs from 1 to 0 whatever the real length is. SVG stroke-dasharray explains that trick step by step.

Reduced motion

Some people turn on a "reduce motion" setting on their device. The prefers-reduced-motion media query detects it, and the first example uses it to show a still picture instead:

@media (prefers-reduced-motion: reduce) {
  .sun, .dot, .line { animation: none; stroke-dashoffset: 0; }
}

When it does not work

What you see Cause Fix
The shape circles around the SVG's corner Default origin is 0 0 of the viewBox transform-box: fill-box and transform-origin: center
The shape jumps to the top-left when it starts A CSS transform replaced the transform attribute Keep the attribute on an outer <g>, animate an inner one
Page CSS has no effect on the SVG It is loaded with <img> or as a background Paste the <svg> inline
A script inside the .svg file does nothing Scripts do not run in SVG used as an image Move the script into the page and use an inline SVG
gsap is not defined in the console Your code runs before the GSAP script tag Put the GSAP <script src> above your code
The line is drawn from the start, no animation Dash length does not match the path length Add pathLength="1", use 1 for dasharray and offset
Nothing moves for one visitor Their device asks for reduced motion Expected; keep the still frame readable

An animation is hard to show in a screenshot, and a GIF loses the buttons and the slider. A link to the working page keeps all of it.

Paste the page into a NOS document and choose Create share link. HTML to link walks through it. The page renders as written, its scripts run and GSAP loads from jsDelivr.

The people you send it to can press Play and scrub the timeline themselves. Anyone with the link can open it without an account, and if you change the code later, the same link shows the new version.

Questions people ask

What is the easiest way to animate an SVG?

Paste the SVG into the HTML page as an inline svg element and add a CSS @keyframes rule to a shape, the same way you animate a div. For rotation or scaling, also set transform-box: fill-box and transform-origin: center on that shape.

Why does my SVG rotate around the corner instead of its centre?

On SVG shapes, transform-origin starts at 0 0 and transform-box starts at view-box, so rotation happens around the top-left of the SVG's coordinate system. Set transform-box: fill-box and transform-origin: center on the shape.

Does an animated SVG still move inside an img tag?

CSS and SMIL animations written inside the SVG file played in our Chromium test. Scripts inside the file did not run, and the page's own CSS could not reach the shapes. To control the animation from the page, use an inline svg.

Do I need GSAP to animate SVG?

No. CSS, SMIL and the Web Animations API all work without a library. GSAP helps with long sequences: a timeline plays steps one after another and can be paused, reversed and scrubbed as a whole.

Is GSAP free?

The GSAP licensing page says GSAP is free for everyone, including commercial use, under its own standard "no charge" license. Read that page for the terms that apply to your project.

Keep reading