DEV Community

Cover image for SMIL Animations in SVG: A Step-by-Step Guide Using a Real Wordmark
angeloscle
angeloscle

Posted on

SMIL Animations in SVG: A Step-by-Step Guide Using a Real Wordmark

What is SMIL? SMIL stands for Synchronized Multimedia Integration Language. It is the W3C standard baked directly into SVG that lets you animate shapes, paths, transforms, colours, and more — zero JavaScript, zero CSS, zero external libraries required. The browser does all the work natively inside the SVG element.

In this guide we will build every animation you see in the roomie wordmark below step by step, explaining why each attribute exists and how to tweak it for your own projects.

┌─────────────────────────────────────────┐
│  r  [💬💬]  m  i  e                     │
│             ─────────                   │
└─────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

👉 View the live animated SVG

Two chat bubbles slide in from below and fade up into position when the SVG loads. The entire effect is ~30 lines of markup.


Table of Contents

  1. The SVG canvas and viewport
  2. Defining reusable assets with <defs>
  3. Painting with gradients
  4. Drawing the chat bubble paths
  5. Your first SMIL animation — <animate> for opacity
  6. Moving things — <animateTransform>
  7. Controlling timing — keyTimes and begin
  8. Natural easing — calcMode and keySplines
  9. Locking the final state — fill="freeze"
  10. Adding atmosphere — glow ellipse and accent line
  11. The finished file
  12. Common tasks and how to tackle them
  13. Browser support and gotchas

1. The SVG canvas and viewport

Every SVG starts with its coordinate system. The viewBox attribute maps internal coordinates to the rendered size.

<svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 420 160"
  width="420"
  height="160"
  role="img"
  aria-label="roomie"
>
  <title>roomie</title>
Enter fullscreen mode Exit fullscreen mode
Attribute Why it matters
viewBox="0 0 420 160" Internal grid: 420 units wide, 160 units tall. All coordinates below live in this space.
width / height Rendered pixel size. Change these to scale the whole logo without touching a single coordinate.
role="img" + aria-label Screen-reader accessibility. Always include these on decorative/logo SVGs.

Task tip: Always design at a comfortable internal resolution (here 420 × 160) and rely on viewBox scaling rather than hard-coding pixel sizes everywhere.


2. Defining reusable assets with <defs>

<defs> is a container for things that are defined but not rendered until referenced via url(#id). Think of it as your SVG stylesheet.

<defs>
  <!-- gradients go here -->
</defs>
Enter fullscreen mode Exit fullscreen mode

Nothing inside <defs> paints pixels on its own. It only activates when another element references it.


3. Painting with gradients

The wordmark uses three gradient types. Here is each one explained.

3a. Radial gradient — the ambient glow

<radialGradient id="glow-b" cx="0.5" cy="0.5" r="0.55">
  <stop offset="0"   stop-color="#3b82f6" stop-opacity="0.25"/>
  <stop offset="1"   stop-color="#3b82f6" stop-opacity="0"/>
</radialGradient>
Enter fullscreen mode Exit fullscreen mode

cx, cy, r use the default gradientUnits="objectBoundingBox", so 0.5 means centre of the element that references this gradient. The gradient fades from 25% opacity blue in the centre to fully transparent at the edge — a classic "light bloom" trick.

3b. Linear gradient — left bubble (indigo → slate)

<linearGradient id="bubbleGradA-b" x1="0.1" y1="0.1" x2="0.9" y2="0.9">
  <stop offset="0"   stop-color="#4f46e5"/>
  <stop offset="0.5" stop-color="#3730a3"/>
  <stop offset="1"   stop-color="#1e1b4b"/>
</linearGradient>
Enter fullscreen mode Exit fullscreen mode

x1/y1 → x2/y2 defines the gradient direction as a diagonal line (top-left to bottom-right). Three stops give depth: a lighter highlight at the top-left, mid-tone in the centre, and a near-black shadow at the bottom-right.

3c. Linear gradient — right bubble (cyan → blue)

<linearGradient id="bubbleGradB-b" x1="0.9" y1="0.1" x2="0.1" y2="0.9">
  <stop offset="0"    stop-color="#06b6d4"/>
  <stop offset="0.55" stop-color="#2563eb"/>
  <stop offset="1"    stop-color="#4f46e5"/>
</linearGradient>
Enter fullscreen mode Exit fullscreen mode

The direction is mirrored (x1="0.9" starts at the right): cyan hits the top-right edge, transitioning into the same indigo as the left bubble — they visually blend where they overlap.

3d. Linear gradient — text

<linearGradient id="textGrad-b" x1="0" y1="0" x2="1" y2="0">
  <stop offset="0"   stop-color="#e0e7ff"/>
  <stop offset="0.4" stop-color="#ffffff"/>
  <stop offset="0.7" stop-color="#a5f3fc"/>
  <stop offset="1"   stop-color="#c7d2fe"/>
</linearGradient>
Enter fullscreen mode Exit fullscreen mode

x1="0" x2="1" with identical y values = pure horizontal sweep. The stops produce a subtle shimmer: near-white in the middle with cool lavender on the left and cyan on the right.

Apply a gradient by setting fill="url(#gradient-id)" on any shape or text element.


4. Drawing the chat bubble paths

Both bubbles use the SVG <path> element with Cubic Bézier curves (C command).

<path
  d="M62 14
     C39 14 21 30 21 51
     C21 60 24.8 68 31 74
     C30.4 81.5 27.5 87.5 22 92.5
     C31 91.8 38.5 89.2 45 85
     C50 86.6 55.8 87.5 62 87.5
     C85 87.5 103 71.5 103 51
     C103 30 85 14 62 14Z"
  fill="url(#bubbleGradA-b)"
  stroke="rgba(129,140,248,0.20)"
  stroke-width="1.2"
  stroke-linejoin="round"
/>
Enter fullscreen mode Exit fullscreen mode

Path anatomy:

Command Meaning
M62 14 Move to the starting point (top-centre of bubble)
C x1 y1, x2 y2, x y Cubic Bézier curve: two control points then the end point
Z Close the path back to the starting point

The "tail" at the bottom-left (points around 22 92.5) is drawn as a separate Bézier arc, giving the classic chat-bubble tail shape.

Subtle inner shine:

<ellipse cx="55" cy="38" rx="22" ry="10" fill="white" opacity="0.06"/>
Enter fullscreen mode Exit fullscreen mode

A white ellipse at 6% opacity in the top-left of the bubble mimics light catching a glossy surface.


5. Your first SMIL animation — <animate> for opacity

The <animate> element changes a single numeric attribute of its parent over time.

<animate
  attributeName="opacity"
  values="0; 1"
  keyTimes="0; 1"
  dur="0.35s"
  begin="0s"
  fill="freeze"
  repeatCount="1"
/>
Enter fullscreen mode Exit fullscreen mode

Place it as a direct child of the element you want to animate — the <g> that wraps the bubble path.

Attribute Value What it does
attributeName opacity Which SVG attribute to animate
values 0; 1 Start at invisible, end at fully opaque
keyTimes 0; 1 Map each value to a position in the timeline (0 = start, 1 = end)
dur 0.35s Total duration of this animation
begin 0s Fire immediately when the SVG is parsed
fill freeze Keep the final value after the animation ends (explained in §9)
repeatCount 1 Play once

Three-stop keyframe example: if you wanted a pulse then fade you'd write:

<animate
  attributeName="opacity"
  values="0; 1; 0.5; 1"
  keyTimes="0; 0.3; 0.7; 1"
  dur="1s"
  fill="freeze"
  repeatCount="1"
/>
Enter fullscreen mode Exit fullscreen mode

6. Moving things — <animateTransform>

While <animate> targets scalar attributes, <animateTransform> handles CSS-transform-style operations: translate, rotate, scale, skewX, skewY.

The left bubble slides up from below:

<animateTransform
  attributeName="transform"
  type="translate"
  values="0 18; 0 -5; 0 0"
  keyTimes="0; 0.65; 1"
  calcMode="spline"
  keySplines="0.22 1 0.36 1; 0.5 0 0.5 1"
  dur="0.7s"
  begin="0s"
  fill="freeze"
  repeatCount="1"
/>
Enter fullscreen mode Exit fullscreen mode

Breaking down values for translate

values for type="translate" uses "x y" pairs separated by semicolons:

Keyframe Value Meaning
0 0 18 Start 18 units below final position
0.65 0 -5 Overshoot 5 units above final position (bounce)
1 0 0 Settle at the natural resting position

The three-point keyframe with an overshoot is the foundation of spring animation. The bubble shoots past its target and gently falls back, feeling physical rather than mechanical.

Right bubble — slightly different timing:

values="-8 21; -8 -3; -8 3"
Enter fullscreen mode Exit fullscreen mode

The x component stays at -8 throughout (its group already has transform="translate(-8, 3)"). The y goes from 21-33, a slightly shorter overshoot so the two bubbles settle with a staggered rhythm.


7. Controlling timing — keyTimes and begin

keyTimes="0; 0.65; 1"
Enter fullscreen mode Exit fullscreen mode

keyTimes is a semicolon-separated list that maps each entry in values to a point in the animation timeline, expressed as a fraction from 0 (start) to 1 (end).

Timeline:  |──────────────────────────────────|
           0                                  1
keyTimes:  0          0.65                   1
values:  "0 18"       "0 -5"               "0 0"
Enter fullscreen mode Exit fullscreen mode

This means:

  • 65% of the time is spent on the main upward slide
  • 35% of the time is spent on the overshoot correction

Staggering animations with begin:

<!-- First element fires at 0s -->
<animateTransform ... dur="0.7s" begin="0s" />

<!-- A second element fires 150ms later -->
<animateTransform ... dur="0.7s" begin="0.15s" />
Enter fullscreen mode Exit fullscreen mode

You can also synchronise to another element's lifecycle:

<!-- Fires when "bubble-a" ends -->
<animateTransform ... begin="bubble-a.end" />
Enter fullscreen mode Exit fullscreen mode

8. Natural easing — calcMode and keySplines

By default SMIL interpolates linearly (calcMode="linear"). For organic motion, switch to calcMode="spline" and provide cubic Bézier curves via keySplines.

calcMode="spline"
keySplines="0.22 1 0.36 1; 0.5 0 0.5 1"
Enter fullscreen mode Exit fullscreen mode

Each keySplines entry corresponds to a segment between consecutive keyframes. For three keyframes you have two segments, hence two spline definitions separated by ;.

Reading a cubic Bézier: x1 y1 x2 y2

"0.22 1 0.36 1"   →   ease-out (slow finish)
"0.5 0 0.5 1"     →   ease-in-out (slow start AND slow finish)
Enter fullscreen mode Exit fullscreen mode

You can visualise these at cubic-bezier.com. The equivalent CSS equivalents are:

SMIL keySpline CSS equivalent
0 0 1 1 linear
0.25 0.1 0.25 1 ease
0 0 0.2 1 ease-out
0.4 0 1 1 ease-in
0.22 1 0.36 1 custom spring-like ease-out

Quick cheat sheet for spring animation:

  • Segment 1 (travel to overshoot): 0.22 1 0.36 1 — fast exit, decelerates hard
  • Segment 2 (correction): 0.5 0 0.5 1 — symmetrical ease so the settle feels balanced

9. Locking the final state — fill="freeze"

After an animation ends, the default behaviour (fill="remove") snaps the element back to its pre-animation value. That means your bubble would teleport back to y=18 and disappear.

fill="freeze"
Enter fullscreen mode Exit fullscreen mode

fill="freeze" tells the animation engine to hold the final keyframe value indefinitely. Always use this for entrance animations that you want to stay visible.


10. Adding atmosphere — glow ellipse and accent line

Ambient glow

<ellipse cx="130" cy="78" rx="100" ry="55" fill="url(#glow-b)"/>
Enter fullscreen mode Exit fullscreen mode

This large translucent ellipse uses the radial gradient from §3a. Because the radial gradient fades to stop-opacity="0" at its edge, the ellipse blends seamlessly into the background — no hard border.

Accent line

<line
  x1="14" y1="134" x2="130" y2="134"
  stroke="url(#bubbleGradB-b)"
  stroke-width="2"
  stroke-linecap="round"
  opacity="0.35"
/>
Enter fullscreen mode Exit fullscreen mode

stroke-linecap="round" caps both ends of the line with half-circles, eliminating the harsh square nub the default produces. The line reuses bubbleGradB-b so the cyan-to-indigo sweep echoes the right bubble above it.


11. The finished file

Here is the complete roomie-blue.svg assembled from all the steps above:

<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 420 160" width="420" height="160"
     role="img" aria-label="roomie">
  <title>roomie</title>
  <defs>
    <desc>Roomie wordmark with chat bubbles.</desc>

    <!-- Ambient glow -->
    <radialGradient id="glow-b" cx="0.5" cy="0.5" r="0.55">
      <stop offset="0" stop-color="#3b82f6" stop-opacity="0.25"/>
      <stop offset="1" stop-color="#3b82f6" stop-opacity="0"/>
    </radialGradient>

    <!-- Left bubble: deep indigo → slate blue -->
    <linearGradient id="bubbleGradA-b" x1="0.1" y1="0.1" x2="0.9" y2="0.9">
      <stop offset="0"   stop-color="#4f46e5"/>
      <stop offset="0.5" stop-color="#3730a3"/>
      <stop offset="1"   stop-color="#1e1b4b"/>
    </linearGradient>

    <!-- Right bubble: electric blue → cyan -->
    <linearGradient id="bubbleGradB-b" x1="0.9" y1="0.1" x2="0.1" y2="0.9">
      <stop offset="0"    stop-color="#06b6d4"/>
      <stop offset="0.55" stop-color="#2563eb"/>
      <stop offset="1"    stop-color="#4f46e5"/>
    </linearGradient>

    <!-- Text shimmer -->
    <linearGradient id="textGrad-b" x1="0" y1="0" x2="1" y2="0">
      <stop offset="0"   stop-color="#e0e7ff"/>
      <stop offset="0.4" stop-color="#ffffff"/>
      <stop offset="0.7" stop-color="#a5f3fc"/>
      <stop offset="1"   stop-color="#c7d2fe"/>
    </linearGradient>
  </defs>

  <!-- Ambient glow bloom -->
  <ellipse cx="130" cy="78" rx="100" ry="55" fill="url(#glow-b)"/>

  <!-- "r" wordmark -->
  <text x="8" y="120"
        fill="url(#textGrad-b)"
        font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"
        font-weight="800" font-size="118" letter-spacing="-4">r</text>

  <!-- Chat bubbles group -->
  <g transform="translate(28, 26)">

    <!-- Left bubble -->
    <g>
      <animateTransform attributeName="transform" type="translate"
        values="0 18; 0 -5; 0 0" keyTimes="0; 0.65; 1"
        calcMode="spline" keySplines="0.22 1 0.36 1; 0.5 0 0.5 1"
        dur="0.7s" begin="0s" fill="freeze" repeatCount="1"/>
      <animate attributeName="opacity" values="0; 1"
        keyTimes="0; 1" dur="0.35s" begin="0s" fill="freeze" repeatCount="1"/>
      <path
        d="M62 14 C39 14 21 30 21 51 C21 60 24.8 68 31 74
           C30.4 81.5 27.5 87.5 22 92.5 C31 91.8 38.5 89.2 45 85
           C50 86.6 55.8 87.5 62 87.5 C85 87.5 103 71.5 103 51
           C103 30 85 14 62 14Z"
        fill="url(#bubbleGradA-b)"
        stroke="rgba(129,140,248,0.20)" stroke-width="1.2" stroke-linejoin="round"/>
      <ellipse cx="55" cy="38" rx="22" ry="10" fill="white" opacity="0.06"/>
    </g>

    <!-- Right bubble -->
    <g transform="translate(-8, 3)">
      <animateTransform attributeName="transform" type="translate"
        values="-8 21; -8 -3; -8 3" keyTimes="0; 0.7; 1"
        calcMode="spline" keySplines="0.22 1 0.36 1; 0.5 0 0.5 1"
        dur="0.85s" begin="0s" fill="freeze" repeatCount="1"/>
      <animate attributeName="opacity" values="0; 1"
        keyTimes="0; 1" dur="0.35s" begin="0s" fill="freeze" repeatCount="1"/>
      <path
        d="M132 17 C112 17 96 31.5 96 50 C96 58 99 65 104.5 70.5
           C104 77.5 101.5 83 97 87.5 C104.5 87 111.5 84.8 117.5 81
           C121.5 82.3 126 83 131 83 C151 83 167 68.5 167 50
           C167 31.5 151 17 132 17Z"
        fill="url(#bubbleGradB-b)"
        stroke="rgba(34,211,238,0.15)" stroke-width="1.2" stroke-linejoin="round"/>
      <ellipse cx="138" cy="37" rx="18" ry="8" fill="white" opacity="0.07"/>
    </g>

    <!-- Connection spark -->
    <circle cx="96" cy="50" r="2.5"  fill="#67e8f9" opacity="0.5"/>
    <circle cx="96" cy="50" r="5"    fill="#67e8f9" opacity="0.12"/>
  </g>

  <!-- "mie" wordmark -->
  <text x="178" y="120"
        fill="url(#textGrad-b)"
        font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"
        font-weight="800" font-size="118" letter-spacing="-4">mie</text>

  <!-- Accent underline -->
  <line x1="14" y1="134" x2="130" y2="134"
        stroke="url(#bubbleGradB-b)" stroke-width="2"
        stroke-linecap="round" opacity="0.35"/>
</svg>
Enter fullscreen mode Exit fullscreen mode

12. Common tasks and how to tackle them

Task: Make the animation loop forever

<!-- Change repeatCount="1" → "indefinite" on both animations -->
<animateTransform ... repeatCount="indefinite"/>
<animate          ... repeatCount="indefinite"/>
Enter fullscreen mode Exit fullscreen mode

For a looping entrance you'll also want fill="remove" (the default) so each iteration resets to the start value.


Task: Trigger the animation on hover (CSS + SMIL hybrid)

SMIL begin accepts event-based triggers:

<animateTransform
  ...
  begin="mouseover"
  end="mouseout"
  fill="freeze"
  repeatCount="1"
/>
Enter fullscreen mode Exit fullscreen mode

For <img src="logo.svg"> embeds this won't fire (the SVG is sandboxed). Use the SVG inline in HTML or via <object> for event-based triggers.


Task: Add a colour-cycling animation

<animate
  attributeName="stop-color"
  values="#4f46e5; #06b6d4; #4f46e5"
  keyTimes="0; 0.5; 1"
  dur="4s"
  repeatCount="indefinite"
  calcMode="spline"
  keySplines="0.4 0 0.6 1; 0.4 0 0.6 1"
/>
Enter fullscreen mode Exit fullscreen mode

Place this directly inside a <stop> element inside a gradient. Note: attributeName="stop-color" uses the hyphenated CSS property name, not a presentation attribute.


Task: Rotate an element around its own centre

<animateTransform type="rotate"> values format: "angle cx cy".

<!-- Spin the connection spark 360° around its own centre -->
<animateTransform
  attributeName="transform"
  type="rotate"
  values="0 96 50; 360 96 50"
  dur="3s"
  repeatCount="indefinite"
/>
Enter fullscreen mode Exit fullscreen mode

96 50 are the cx/cy of the circle — the pivot point.


Task: Scale an element for a "pulse" badge effect

<animateTransform
  attributeName="transform"
  type="scale"
  values="1; 1.15; 1"
  keyTimes="0; 0.5; 1"
  dur="1.4s"
  repeatCount="indefinite"
  calcMode="spline"
  keySplines="0.4 0 0.6 1; 0.4 0 0.6 1"
  additive="sum"
/>
Enter fullscreen mode Exit fullscreen mode

additive="sum" is critical when the element already has a transform attribute — it adds to the existing transform instead of replacing it.


Task: Stagger multiple elements with begin offsets

<!-- Element 1: fires at 0s -->
<animate id="a1" ... begin="0s"    dur="0.6s"/>

<!-- Element 2: fires after element 1 ends -->
<animate         ... begin="a1.end" dur="0.6s"/>

<!-- Element 3: fires 0.2s after element 1 ends -->
<animate         ... begin="a1.end+0.2s" dur="0.6s"/>
Enter fullscreen mode Exit fullscreen mode

The roomie logo uses a simpler form — both bubbles start at begin="0s" but have different dur values (0.7s vs 0.85s), creating a natural stagger through speed difference rather than delayed starts.


Task: Debug an animation that isn't playing

  1. Check repeatCount="1" + fill="freeze" — without fill="freeze" the element returns to its original state and looks broken.
  2. Confirm the SVG is inline or in <object><img> tags block SMIL.
  3. Inspect keyTimes count — must equal the count of semicolon-delimited values in values.
  4. Inspect keySplines count — must equal keyTimes count - 1.
  5. Firefox quirkattributeName="transform" must be lowercase; Firefox is case-sensitive.

13. Browser support and gotchas

Browser SMIL support Notes
Chrome / Edge ✅ Full Chromium has had full SMIL support since 2013
Firefox ✅ Full Case-sensitive attribute names
Safari ✅ Full iOS Safari too
IE 11 ❌ None IE never supported SMIL
<img> embed ⚠️ Partial Declarative animations play; event-based triggers (mouseover) do not
background-image CSS ❌ None SVG animations are blocked entirely

Use SMIL when:

  • You want self-contained SVG files (logos, icons, loaders)
  • You need animations in email clients that support SVG
  • You want zero runtime dependencies

Use CSS animations instead when:

  • You need prefers-reduced-motion media query support (SMIL ignores it by default — add JS to pause if needed)
  • You want DevTools animation inspector support
  • You're animating SVGs that are part of a larger component tree

Wrapping up

SMIL gives you a remarkably complete animation toolkit inside a plain XML file. The roomie wordmark shows how much you can achieve in ~30 lines:

  • ✅ Gradient fills (radial + linear)
  • ✅ Smooth entrance animation with spring overshoot
  • ✅ Opacity fade-in
  • ✅ Natural cubic Bézier easing
  • ✅ Staggered multi-element timing
  • ✅ Atmospheric glow and subtle shines

The mental model is simple: put your <animate> or <animateTransform> inside the element it should affect, describe what changes in values, when in keyTimes, and how fast in dur. The rest is taste.


Have questions or want to share your own SMIL animation? Drop them in the comments below.

Top comments (0)