Iurii RoguliaIurii Rogulia
AboutServicesPricingProjectsStackReviewsPhrasesBlog
Contact
Iuriiย ships.

Iurii Rogulia, IT partner for business & fractional CTO. 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)

On this page

  • The CSS Transform Trap
  • One Duration to Rule Them All
  • ease-in-out Lies About When Things Arrive
  • DOM Order Is Z-Index in SVG
  • The Infinite Stack Illusion
  • Scaling Font Awesome Paths Without Illustrator
  • Takeaways

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 Rogulia

Working on something like this?

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. Iurii took it over and found what I couldn't see: authentication handled four different ways, tests that only asserted what the code already did, and a dependency list half of which was unused. He didn't rewrite it from scratch โ€” he told me honestly what was salvageable, ripped out the dead code, and got it to something a real team could build on. Two weeks and it went from 'looks done' to actually shippable.

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. Iurii profiled it instead of guessing, found a handful of unindexed queries and a runaway N+1 that were doing most of the damage, and had page loads back under a second within the first week. He fixed the actual causes rather than throwing more server at it, and wrote up what he changed so we wouldn't repeat the same mistakes.

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. Iurii's audit covered IndexNow setup, a sitemap that was hitting size limits and dropping URLs silently, and canonical tags that were inconsistent between AMP and non-AMP versions. The IndexNow integration alone moved median time-to-index for Bing from days to under an hour. Report was direct, no fluff, exactly what we needed.

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
Gated B2B Pricing in Next.js: Hiding Prices Behind Per-Customer Access
July 24, 2026ยท 9 min
Gated B2B Pricing in Next.js: Hiding Prices Behind Per-Customer Access

Gated B2B pricing in Next.js: per-account price lists, server-side access control so prices never leak to crawlers, and a deal-record data model where every

Stack

Next.jsReactTypeScript

Databases

PostgreSQL

Topics

B2BE-commerceAuthSSRArchitectureSecurity
Building a React Product Configurator with Zustand and Motion
April 21, 2026ยท 12 min
Building a React Product Configurator with Zustand and Motion

How to build a React product configurator with Zustand state, DaisyUI components, Motion animations, and live SVG preview โ€” architecture, pricing logic, and

Stack

Next.jsReactTypeScript

Libraries

DaisyUIFramer MotionZustandnanoid

Topics

Product ConfiguratorUI/UXE-commerceInteractive
Real-Time Dashboard in Next.js with TanStack Query + Zustand
April 16, 2026ยท 12 min
Real-Time Dashboard in Next.js with TanStack Query + Zustand

Real-time Next.js admin dashboard: TanStack Query polling, Zustand for filter state, Sentry error boundaries per panel, and a zero-dependency bar chart.

Stack

Next.jsReactTypeScript

Libraries

TanStack QueryZustandDrizzle ORM

Services

SentryStripe

Topics

Admin DashboardSaaSData VisualizationArchitecture