Iurii RoguliaIurii Rogulia
AboutServicesPricingProjectsStackReviewsPhrasesBlog
Contact
Iuriiย ships.

Iurii Rogulia, senior full-stack software engineer. Professionally building software since 2001.

Think of a number
PricingQuality checklistPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland ๐Ÿ‡ซ๐Ÿ‡ฎ

[email protected]
  1. Home
  2. /
  3. Blog
  4. /
  5. SVG Keyframe Animation in Pure CSS (No Library)

Iuriiย Animates: SVG Keyframe Animation in Pure CSS (No Library)

How I built a multi-track looping illustration for HTPBE? โ€” and fixed the silent transform conflict that snaps your element to (0,0).

March 5, 2026ยท 7 min read

CSS SVG animation with pure @keyframes: sync multiple tracks, fix the SVG transform override trap, and build smooth loops without GSAP or Framer Motion.

Stack

TypeScriptReact

Topics

AnimationPerformanceSVG
SVG Keyframe Animation in Pure CSS (No Library)

I needed an animated illustration for a product promo block. The concept was straightforward: a PDF flies out of a stack on the left, disappears into a cloud in the center, and a coloured result document emerges on the right and drops into a folder. The folder bounces. The whole thing loops forever.

Sounds like an afternoon of work. The first serious attempt ended with the folder icons snapping to the top-left corner of the SVG the moment the animation started. No errors in the console. No warnings in DevTools. The SVG attributes looked correct. The CSS looked correct. It took an hour to find the cause โ€” and once I understood it, I realized it's one of those things that almost nobody mentions because everyone hits it once, fixes it by accident, and moves on.

I didn't want to pull in GSAP for this. A 30 KB animation library for a looping illustration felt wrong. Framer Motion animates React DOM nodes, not SVG paths natively โ€” overkill and the wrong abstraction. SMIL (<animate>, <animateTransform>) has spotty browser support and has been deprecated in Chrome. Canvas would require a JS runtime on the animation thread and lose SVG's free scaling and accessibility. So pure CSS @keyframes it was. Here's what I learned building it.

This is the finished animation. To see it live in the product, run any PDF through htpbe.tech โ€” it appears in the API promo block on the results page after verification. The full product context is in the HTPBE? PDF analysis project card.

PDFPDFPDFPDFPDF

The CSS Transform Trap

This is the one you will hit. If an SVG element has both a transform attribute and a CSS @keyframes animation that touches transform, the browser uses only the CSS value and silently ignores the attribute. The element snaps to (0,0) at animation start.

// โŒ Broken: CSS keyframes silently override the SVG transform attribute
// No errors, no warnings โ€” element just jumps to (0,0) on play
<g transform="translate(690, 5)" className="apr-green-folder">
  <rect x="0" y="0" width="56" height="68" rx="4" fill="#22C55E" />
</g>

The spec says CSS transform and presentation attributes are separate cascade layers, and CSS wins. In theory, transform-origin and transform-box complicate this further. In practice, the symptom is always the same: your carefully positioned element snaps to the origin.

The fix is to separate position from animation into two nested <g> elements:

// โœ… Fixed: outer <g> carries position via SVG attribute, no CSS class
//           inner <g> carries CSS animation class, no SVG transform attribute
<g transform="translate(690, 5)">
  <g className="apr-green-folder">
    <rect x="0" y="0" width="56" height="68" rx="4" fill="#22C55E" />
    <rect x="50" y="46" width="14" height="22" rx="3" fill="#15803D" />
  </g>
</g>
 
// CSS:
// .apr-green-folder {
//   animation: apr-green-bounce 12s linear infinite;
//   transform-box: fill-box;
//   transform-origin: center;
// }

For flying documents, I went the other direction: no transform attribute at all. The CSS keyframe carries the full translate(x, y) from the start position. That way there is nothing to conflict with.

One Duration to Rule Them All

Related service

MVP Development

Building a Next.js product that needs polished animations, SVG illustrations, or performance-conscious frontend work? I ship the full stack โ€” UI through deployment.

More about this service โ†’

The animation has six independent tracks: the PDF leaving the stack, the green and red documents flying from cloud to folder, two folder bounce animations, and a cloud pulse. Coordinating six animation-delay values is a maintenance nightmare โ€” change one timing and you have to recalculate everything.

The simpler approach: give every animation the same duration (12s) and express all timing as percentages. Percentages become absolute time. The full cycle has two passes โ€” pass 1 at 0โ€“50% (6 seconds) and pass 2 mirroring it at 50โ€“100%. No delays, no offsets, no arithmetic to maintain.

/* PDF leaves stack at 4%, arrives at cloud at 24%, snaps back invisibly, reappears at 27% */
/* Pass 2 mirrors at +50%: leaves at 54%, arrives at 74%, reappears at 77% */
@keyframes apr-pdf {
  0% {
    transform: translate(8px, 66px);
    opacity: 1;
  }
  4% {
    transform: translate(8px, 66px);
    opacity: 1;
  }
  24% {
    transform: translate(357px, 66px);
    opacity: 1;
  }
  25% {
    transform: translate(357px, 66px);
    opacity: 0;
  } /* behind cloud */
  26% {
    transform: translate(8px, 66px);
    opacity: 0;
  } /* snap back */
  27% {
    transform: translate(8px, 66px);
    opacity: 1;
  } /* reappear */
  50% {
    transform: translate(8px, 66px);
    opacity: 1;
  }
  54% {
    transform: translate(8px, 66px);
    opacity: 1;
  }
  74% {
    transform: translate(357px, 66px);
    opacity: 1;
  }
  75% {
    transform: translate(357px, 66px);
    opacity: 0;
  }
  76% {
    transform: translate(8px, 66px);
    opacity: 0;
  }
  77% {
    transform: translate(8px, 66px);
    opacity: 1;
  }
  100% {
    transform: translate(8px, 66px);
    opacity: 1;
  }
}
 
.apr-pdf {
  animation: apr-pdf 12s ease-in-out infinite;
}

The 25%โ€“27% cluster is the invisible reset: the PDF reaches the cloud and goes transparent, snaps back to the starting position instantly while invisible, then reappears on the stack. Users see a document fly into a cloud and a different document emerge on the other side. The snap happens behind the cloud.

ease-in-out Lies About When Things Arrive

I spent more time than I expected on the folder bounce timing. The document arrives at the folder at 42% in its keyframe. The folder bounce starts at 42%. They should land together. They don't.

ease-in-out means the animation decelerates as it approaches the keyframe endpoint. Visually, the document "feels" like it arrives around 39% even though the coordinate reaches the target at 42%. The folder pulse triggered at 42% looked like it was reacting after the document hit.

The fix is to move the bounce keyframes 3% earlier and use linear timing:

@keyframes apr-green-bounce {
  /* Start bounce at 39% (3% before document's 42% keyframe) to compensate ease-in-out */
  0%,
  39%,
  47%,
  100% {
    transform: scale(1);
  }
  41% {
    transform: scale(1.12);
  }
  45% {
    transform: scale(0.97);
  }
}
 
.apr-green-folder {
  animation: apr-green-bounce 12s linear infinite; /* linear, not ease-in-out */
  transform-box: fill-box;
  transform-origin: center;
}

transform-box: fill-box is essential here. Without it, transform-origin: center is relative to the SVG viewport, not the element's own bounding box. The folder would scale around the wrong point.

DOM Order Is Z-Index in SVG

For more on how CSS and React intersect in production UIs, see React Masonry Layout: Why the Popular Reorder Trick Fails โ€” another place where browser rendering behaves differently from what tutorials promise.

SVG has no z-index. Elements are painted in DOM order โ€” later elements appear on top. If you need a document to fly behind the cloud, it must appear before the cloud in the markup. This is not a CSS trick. It is the SVG rendering model.

{/* Elements rendered in this order: later = on top */}
<g className="apr-green-fly"> ... </g>  {/* โ† behind cloud */}
<g className="apr-red-fly">   ... </g>  {/* โ† behind cloud */}
<g className="apr-circle">    ... </g>  {/* cloud: on top of documents above */}
{/* Folders come after cloud, so they render on top of everything */}
<g transform="translate(690, 5)">
  <g className="apr-green-folder"> ... </g>
</g>

When the green document flies right, it passes behind the cloud naturally โ€” because it was drawn before the cloud in the DOM. No clip-path, no z-index, no workaround.

The Infinite Stack Illusion

The stack appears to never shrink. Three static PDFs are always present and never animated. The flying document renders on top of them. When it departs, the stack looks identical because the third static document occupies the same position as the flying one at rest.

{/* Three static docs โ€” never animated, always visible */}
<rect x="14" y="72" width="56" height="68" rx="3"
      fill="white" stroke="#E2E8F0" strokeWidth="1.5" />  {/* back */}
<rect x="11" y="69" width="56" height="68" rx="3"
      fill="white" stroke="#E2E8F0" strokeWidth="1.5" />  {/* middle */}
<rect x="8"  y="66" width="56" height="68" rx="3"
      fill="white" stroke="#E2E8F0" strokeWidth="1.5" />  {/* front โ† same (x,y) as flying PDF */}
 
{/* Flying PDF โ€” covers the front static doc while at rest on the stack */}
<g className="apr-pdf">
  <rect x="0" y="0" width="56" height="68" rx="3"
        fill="white" stroke="#E2E8F0" strokeWidth="1.5" />
  ...
</g>

The flying PDF's keyframe starts at translate(8px, 66px) โ€” exactly the front static doc's position. At rest they are indistinguishable. When the animation plays, the flying doc lifts off and the static one underneath becomes visible. The stack appears to always have the same number of documents.

Scaling Font Awesome Paths Without Illustrator

Icons inside folders (checkmark and cross) come from Font Awesome SVG paths embedded directly. No icon library, no extra request. Scaling an FA path to a specific size and position requires one calculation:

transform="translate(target_center_x target_center_y) scale(s) translate(-path_center_x -path_center_y)"

Where s = target_size / source_bbox_width.

// FA checkmark: viewBox 640ร—640, bbox (92,123)โ€“(548,544), center โ‰ˆ (320.6, 334.2)
// Target: centered at (28, 30) in a 56ร—68 folder, roughly 25px wide
// scale = 25 / (548 - 92) = 25 / 456 โ‰ˆ 0.055 โ†’ rounded to 0.060 for visual weight
 
<path
  transform="translate(28 30) scale(0.060) translate(-320.6 -334.2)"
  d="M530.8 134.1C545.1 144.5 548.3 164.5 537.9 178.8
     L281.9 530.8C276.4 538.4 267.9 543.1 258.5 543.9
     C249.1 544.7 240 541.2 233.4 534.6
     L105.4 406.6C92.9 394.1 92.9 373.8 105.4 361.3
     C117.9 348.8 138.2 348.8 150.7 361.3
     L252.2 462.8L486.2 141.1
     C496.6 126.8 516.6 123.6 530.9 134z"
  fill="white"
/>

The path bounding box numbers come from opening the FA SVG in a browser and running getBBox() on the path element in the console.

Takeaways

  1. Never put a CSS animation and an SVG transform attribute on the same element. CSS wins silently. Separate them into two nested <g> elements โ€” outer for position, inner for animation.

  2. One duration, no delays. If all tracks share the same animation-duration, percentages become absolute time. Changing one track's timing doesn't cascade into recalculating animation-delay everywhere else.

  3. ease-in-out shifts the visual arrival point. For animations that trigger a reaction (bounce, pulse, scale), start the reaction 2โ€“4% before the keyframe where the trigger arrives. Test by feel, not by math.

  4. DOM order is z-index in SVG. If element A must appear behind element B, put A earlier in the markup. This is the spec, not a quirk.

  5. Embed FA paths with translate(target) scale(s) translate(-source_center). No Illustrator, no icon library, no extra HTTP request. Calculate the scale from getBBox() in the browser console.

If you are building an MVP that includes marketing pages, product demos, or onboarding flows, the same techniques apply โ€” pure CSS avoids runtime JS, scales freely, and keeps bundle size in check. For more CSS-from-first-principles work, see the Dynamic OG Images post where I ran into a similar "documented approach breaks in production" problem.

Iurii RoguliaAvailable

MVP Development

Need polished frontend work โ€” animations, SVG, performance โ€” shipped as part of a production product? I own the full stack from UI to deployment.

More about this service

Relevant client work

View all projects
HTPBE? โ€” PDF Verification Workflow Animation
HTPBE? โ€” PDF Verification Workflow Animation
March 4, 2026
HTPBE? โ€” PDF Verification Workflow Animation

Looping SVG animation illustrating a PDF verification pipeline โ€” document stack, cloud processing, and green/red folder sorting โ€” built with pure CSS

pi-pi.ee โ€” Live Custom-Colour Preview
pi-pi.ee โ€” Live Custom-Colour Preview
July 28, 2026
pi-pi.ee โ€” Live Custom-Colour Preview

Recolour a product photo to any colour in the browser, instantly โ€” an SVG duotone filter tints transparent master images on the fly, so a shop can offer

Pikkuna โ€” Real-time SVG Product Configurator
Pikkuna โ€” Real-time SVG Product Configurator
March 10, 2025
Pikkuna โ€” Real-time SVG Product Configurator

Client-side SVG configurator with live preview for designing vinyl curtains and roofing panels.

What clients say

โ€œ

I'd built most of our MVP with Cursor and it looked finished โ€” it compiled, the tests were green, the demo worked. It just wouldn't survive real users.

Sebastian Falk ๐Ÿ‡ธ๐Ÿ‡ช

Founder

Stack

Next.jsTypeScript

Topics

Technical DebtAICode ReviewArchitecture
โ€œ

Our app had slowed to a crawl and the previous developer had left no documentation. Customers were starting to churn over load times.

Owen Pritchard ๐Ÿ‡ฌ๐Ÿ‡ง

CTO

Databases

PostgreSQL

Topics

Technical DebtPerformanceProcess
โ€œ

We publish 20-30 news articles per day and indexing latency was killing us โ€” by the time Google crawled a story, the news cycle had moved on.

Andrei Popescu ๐Ÿ‡ท๐Ÿ‡ด

Engineering Manager

Topics

SEOIndexNowArchitecturePerformance

Related articles

React Masonry Layout: Fix Column Order (without a Library)
March 11, 2026ยท 9 min
React Masonry Layout: Fix Column Order (without a Library)

React masonry layout that keeps left-to-right card order with variable heights โ€” where the popular CSS column-count trick silently breaks.

Stack

ReactTypeScript

Topics

ArchitectureUI/UXPerformance
Technical SEO for Next.js: SSR, JSON-LD, and Sitemaps
December 15, 2025ยท 3 min
Technical SEO for Next.js: SSR, JSON-LD, and Sitemaps

Technical SEO built into Next.js: server-side rendering, dynamic meta tags, JSON-LD structured data, and automatic sitemap generation โ€” no plugins, just code.

Stack

Next.jsReactTypeScript

Topics

SEOArchitecturePerformance
Halloween Effects for Any Website, Turned On by One CSS Class
August 28, 2026ยท 10 min
Halloween Effects for Any Website, Turned On by One CSS Class

halloween.js adds Halloween decorations to any website โ€” one class flips them on, a real-calendar season window turns them off automatically, and a

Stack

TypeScript

Services

npmGitHub Actions

Topics

Open SourceVanilla JSAnimationAccessibility
Batch PDF Generation in Node.js Without Running Out of Memory
August 12, 2026ยท 9 min
Batch PDF Generation in Node.js Without Running Out of Memory

Batch PDF generation in Node.js without OOM: concurrency limits, back-pressure, releasing renderers between docs, and why Promise.all(map) fails at scale.

Stack

Node.jsTypeScript

Libraries

react-pdfPuppeteerp-limit

Topics

PDFDocument AutomationPerformanceArchitecture
B2B Quote-to-Order Flow in Next.js: A State Machine That Doesn't Drift
August 7, 2026ยท 10 min
B2B Quote-to-Order Flow in Next.js: A State Machine That Doesn't Drift

B2B quote-to-order flow in Next.js: an RFQ โ†’ quote โ†’ order state machine, per-deal line items, guarded status transitions, and converting an accepted quote

Stack

Next.jsReactTypeScript

Databases

PostgreSQL

Topics

B2BE-commerceArchitectureSSRSales Automation