Introduction
In this article we build DeadlineLoader — a 581×158px animated progress bar featuring a walking Death character that carries a swinging scythe, a panicked designer scribbling faster as time runs out, a red fill that sweeps across the bar over 20 seconds, fire that erupts at the end, and a "Deadline 7 days" label that wipes from white to red as the countdown ticks to zero. The entire scene is rendered in a single inline SVG. Interactions are driven by CSS keyframe animations and a small vanilla JS controller with no dependencies — no jQuery, no frameworks, no build step.
All design values live in a single frozen CONFIG object in JavaScript and a :root block in CSS. Changing the cycle duration, day count, or writing-speed ramp requires editing one place only. The JS is split into clearly separated layers: setup, per-cycle logic, and boot — each function documented with JSDoc and returning a cancel handle so timers never leak.
How to animate SVG elements with CSS keyframes using transform-box: fill-box for correct pivot points — how to synchronise a JS countdown with a CSS animation cycle — how to build a colour-wipe text effect using two absolutely-positioned mask elements pre-built in HTML — how to ramp an animation speed over time using scheduled setTimeout calls — how to structure vanilla JS with frozen config objects, single-responsibility functions, and cancel handles — how to keep SVG clip paths and defs clean using the modern href attribute instead of the deprecated xlink:href.
How It Works
The scene is one SVG with five independently animated layers. Each layer is a group of SVG paths styled by a CSS class. The red progress fill is a <rect> clipped to the shape of the rail, animated with an x attribute sliding from −100% to −3%. Death is a group translated across the X axis. The arm and scythe are child paths of the death group, rotating around their left-center origin. The designer arm pivots at its shoulder. The flames toggle opacity near the end of the cycle.
The progress "fill" is not a progress element — it is a clipped <rect> whose x attribute slides from left to right, revealing itself through the shape of the rail.
The arm and scythe are the trickiest part. Without transform-box: fill-box, SVG transforms pivot around the SVG viewport origin (0,0) — which is the top-left corner of the entire image. Setting transform-box: fill-box makes the transform-origin relative to the element's own bounding box, so transform-origin: 0% 50% becomes the left-center of the arm path — exactly the shoulder joint. Both the arm and the scythe share the same keyframe, so they always move as one unit.
Step 1 — CSS Custom Properties & JS Config
All timing and colour values live in two places: a :root block for CSS-driven values and a frozen CONFIG object for JS-driven values. The cycle duration is the only value that must match in both places — it drives both the CSS animation-duration and the JS setInterval.
:root {
/* Colours */
--color-bg: #000000;
--color-primary: #be002a;
--color-character: #fefffe;
/* Timing — must match CONFIG.cycleDuration in JS */
--duration-cycle: 20s;
--duration-arm-swing: 1.2s;
/* Typography */
--font-label: 'Oswald', sans-serif;
/* Scene dimensions */
--scene-width: 581px;
--scene-height: 158px;
}const CONFIG = Object.freeze({
/** Total cycle duration in seconds. Must match CSS --duration-cycle. */
cycleDuration: 20,
/** Starting day count shown in the label. */
totalDays: 7,
/**
* Writing-speed ramp for the designer arm.
* Each entry: [delayMs, animationDuration]
* Simulates mounting deadline pressure.
*/
writeRamp: [
[ 0, '1.5s'],
[ 4000, '1s'],
[ 8000, '0.7s'],
[12000, '0.3s'],
[15000, '0.2s'],
],
});Freezing the CONFIG object prevents accidental mutation at runtime. Since all timer durations are derived from CONFIG.cycleDuration at the moment each function runs, a late mutation would cause the JS timers and CSS animations to drift out of sync. Freezing makes this class of bug impossible. It also signals clearly to other developers that this object is a constant, not mutable state.
Step 2 — The Markup
The component is a .scene div containing one SVG and one label paragraph. The SVG holds all animated characters. The label paragraph contains the colour-wipe masks pre-built in HTML — no JavaScript DOM manipulation needed on load, which eliminates the double-render flash that happens when JS rewrites innerHTML after the first paint.
<div class="scene">
<svg class="scene__svg" viewBox="0 0 581 158"
preserveAspectRatio="none" aria-hidden="true">
<defs>
<!-- Clip rect for fire area -->
<!-- Clip path for progress rail shape -->
</defs>
<!-- Layer 1: Flames (clipped to fire box) -->
<g clip-path="url(#clip-fire)">
<path class="flame flame--red" ... />
<path class="flame flame--yellow" ... />
<path class="flame flame--white" ... />
</g>
<!-- Layer 2: Rail (white track shape) -->
<g> ... </g>
<!-- Layer 3: Red progress sweep -->
<rect class="progress-fill" clip-path="url(#clip-trail)"
fill="#be002a" x="-100%" y="34" width="586" height="103" />
<!-- Layer 4: Death character -->
<g class="death-group">
<path ... /> <!-- robe/body -->
<path class="death-arm" ... /> <!-- arm — shares swing keyframe -->
<path class="death-tool" ... /> <!-- scythe — shares swing keyframe -->
</g>
<!-- Layer 5: Designer character -->
<path ... /> <!-- body -->
<circle ... /> <!-- head -->
<g class="designer-arm-group" id="js-designer-arm">
<path ... /> <!-- arm -->
<path ... /> <!-- pen -->
</g>
</svg>
<!-- Colour-wipe label — masks pre-built in HTML, no JS rewrite -->
<p class="deadline-label" id="js-label" aria-live="polite">
<span class="deadline-label__mask deadline-label__mask--red">
<span class="deadline-label__inner">
Deadline <span class="deadline-label__day">7</span> days
</span>
</span>
<span class="deadline-label__mask deadline-label__mask--white">
<span class="deadline-label__inner">
Deadline <span class="deadline-label__day">7</span> days
</span>
</span>
</p>
</div>| Element | Role | Animated |
|---|---|---|
| .progress-fill | Red <rect> clipped to rail shape — sweeps left to right | Yes — x from −100% to −3% |
| .death-group | Death body + arm + scythe group — walks across the bar | Yes — translateX 0 → 520px |
| .death-arm | Arm path — pivots from shoulder (transform-origin: 0% 50%) | Yes — rotate −25° ↔ 20° |
| .death-tool | Scythe — same keyframe as arm, moves as one unit | Yes — same as death-arm |
| .designer-arm-group | Writing arm + pen — JS ramps speed over cycle | Yes — rotate 0° ↔ −10°, speed ramps |
| .flame | Three flame layers — hidden until 74% of cycle | Yes — opacity + scale flicker |
| .deadline-label__mask--red | Red text overlay — width grows from 0 to 98% | Yes — width 0 → 98% |
| .deadline-label__mask--white | White text baseline — always full width behind red mask | No |
| .deadline-label__day | Day number span — updated by JS countdown | JS — textContent 7 → 0 |
Step 3 — The Red Progress Fill
The fill is a plain <rect> with width="586" — wider than the viewBox — starting at x="-100%". A CSS keyframe animates x from −100% to −3%, sliding the rectangle into view from left to right. The rect is clipped to the shape of the rail using a <clipPath> that references the same path data as the white rail group, so the red sweep exactly follows the rail contour including the ramp on the left and the desk section on the right.
.progress-fill {
animation: anim-progress var(--duration-cycle) linear infinite;
}
@keyframes anim-progress {
from { x: -100%; }
to { x: -3%; }
}The rail has a small ramp section on the far right that sits above and to the left of the fire/end zone. Stopping at −3% means the fill reaches just past the right edge of the visible rail area without overflowing into the fire box. Stopping at 0% would overshoot. The exact value depends on the rail geometry — inspect the viewBox to tune it for your own SVG.
Step 4 — Walking Death
The death-group starts off-screen to the left (the body paths begin around x=−80 in SVG coordinates). A multi-step translateX keyframe walks it from 0px to 520px over the full cycle duration. The steps are unevenly spaced to give a slightly irregular walking pace — Death hesitates briefly at the start (0%–6% both at 0px) before beginning to walk.
.death-group {
animation: anim-walk var(--duration-cycle) ease infinite;
}
@keyframes anim-walk {
0% { transform: translateX(0px); }
6% { transform: translateX(0px); } /* brief pause at start */
10% { transform: translateX(100px); }
15% { transform: translateX(140px); }
25% { transform: translateX(170px); }
35% { transform: translateX(220px); }
45% { transform: translateX(280px); }
55% { transform: translateX(340px); }
65% { transform: translateX(370px); }
75% { transform: translateX(430px); }
85% { transform: translateX(460px); }
100% { transform: translateX(520px); }
}Step 5 — Arm & Scythe: The transform-box Fix
This is the most important technical detail in the whole component. Without transform-box: fill-box, every SVG transform pivots around the SVG viewport origin — the top-left corner of the image at coordinate (0,0). Setting transform-box: fill-box changes the coordinate system so transform-origin is relative to the element's own bounding box. This makes transform-origin: 0% 50% mean "the left-center of this path" — exactly the shoulder joint where the arm connects to the body.
Without transform-box: fill-box, your SVG elements will appear to fly off into empty space when rotated. It is the single most common SVG animation mistake.
The arm and scythe share exactly the same keyframe. This is intentional — the scythe is held in Death's hand, so it must move with the arm as a single rigid unit. Giving the scythe its own independent keyframe or animation-delay causes the weapon to visibly separate from the hand, which was the bug we fixed. One keyframe, two elements, perfect sync.
/* The critical fix: fill-box makes transform-origin
relative to the element, not the SVG viewport. */
.death-arm,
.death-tool {
transform-box: fill-box;
transform-origin: 0% 50%; /* left-center = shoulder joint */
animation: anim-arm-swing var(--duration-arm-swing) ease-in-out infinite;
}
/* Single pendulum keyframe — used by BOTH arm and tool */
@keyframes anim-arm-swing {
0% { transform: rotate(-25deg); }
50% { transform: rotate(20deg); }
100% { transform: rotate(-25deg); }
}| Setting | Without fix | With fix |
|---|---|---|
| transform-box | content-box (default) | fill-box |
| transform-origin | Relative to SVG viewport (0,0) | Relative to element bounding box |
| transform-origin: 0% 50% | Top-left corner of SVG image | Left-center of the arm path (shoulder) |
| Rotation result | Element flies far off-screen | Element pivots naturally at shoulder |
Step 6 — Designer Arm Speed Ramp
The designer arm uses the same transform-box: fill-box fix with transform-origin: 0% 50% for its shoulder pivot. The keyframe rotates the arm slightly up and down to simulate pen strokes. What makes this special is the JS-driven speed ramp — the animation-duration starts at 1.5s and shortens to 0.2s by the end of the cycle, making the designer write faster and faster as the deadline approaches.
.designer-arm-group {
transform-box: fill-box;
transform-origin: 0% 50%;
/* JS overrides animation-duration progressively */
animation: anim-write var(--duration-cycle) ease infinite;
}
@keyframes anim-write {
0% { transform: rotate(0deg); }
16% { transform: rotate(-8deg); }
32% { transform: rotate(0deg); }
48% { transform: rotate(-10deg); }
65% { transform: rotate(0deg); }
83% { transform: rotate(-7deg); }
100% { transform: rotate(0deg); }
}/**
* Schedule step-wise speed increases on the designer's writing arm.
* Returns a cancel function that clears all pending timeouts.
* @returns {() => void}
*/
function scheduleWritingRamp() {
const ids = CONFIG.writeRamp.map(([delay, duration]) =>
setTimeout(() => {
els.designerArm.style.animationDuration = duration;
}, delay)
);
return () => ids.forEach(clearTimeout);
}Step 7 — The Flame Layers
The fire is three stacked SVG paths — red, yellow, and white — all clipped to a small rect above the end of the rail. Each flame has two simultaneous animations: anim-flame-show controls visibility (opacity 0 until 74% of the cycle, then opacity 1 from 80%–99%), and a fast flicker keyframe (120ms or 100ms) independently scales each flame to give organic flickering. The show/hide animation duration is set by JS to match the cycle duration, keeping it synchronised.
.flame {
opacity: 0;
transform-origin: center bottom;
}
.flame--red {
animation:
anim-flame-show var(--duration-cycle) ease infinite,
anim-flame-red 120ms ease infinite;
}
/* Flames are hidden until 74% of the cycle */
@keyframes anim-flame-show {
0%, 74% { opacity: 0; }
80%, 99% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes anim-flame-red {
0% { transform: translateY(-30px) scale(1, 1); }
25% { transform: translateY(-30px) scale(1.1, 1.1); }
75% { transform: translateY(-30px) scale(0.8, 0.7); }
100% { transform: translateY(-30px) scale(1, 1); }
}Flames have two animation-duration values separated by a comma. Setting el.style.animationDuration = "20s" would overwrite both slots, destroying the 120ms flicker. The solution is a utility function that reads the current computed value, splits it by comma, replaces only the target slot (index 0 = the show animation), then rejoins. This lets JS control the cycle timing without touching the flicker timing at all.
/**
* Update one slot in a comma-separated animation-duration list.
* @param {Element} el
* @param {string} duration e.g. "20s"
* @param {number} [slot=0] Zero-based index
*/
function setAnimationDurationSlot(el, duration, slot = 0) {
const durations = getComputedStyle(el)
.animationDuration
.split(',')
.map(s => s.trim());
durations[slot] = duration;
el.style.animationDuration = durations.join(', ');
}Step 8 — The Colour-Wipe "Deadline N days" Label
The label shows "Deadline 7 days" in white, with a red version sweeping over it from left to right as the cycle progresses. This is achieved by two absolutely-positioned mask spans stacked on top of each other inside the label. The white mask is always full-width. The red mask starts at width: 0 and grows to 98% via a CSS animation. Both contain identical text — the growing red mask reveals the red version on top of the white one.
The masks must be pre-built in HTML — not injected by JavaScript after load. JS-injected masks always cause a brief flash of the original text before the script runs, producing a visible double-render on first paint.
<!-- Both masks written directly in HTML — no JS injection -->
<p class="deadline-label" id="js-label" aria-live="polite">
<span class="deadline-label__mask deadline-label__mask--red">
<span class="deadline-label__inner">
Deadline <span class="deadline-label__day">7</span> days
</span>
</span>
<span class="deadline-label__mask deadline-label__mask--white">
<span class="deadline-label__inner">
Deadline <span class="deadline-label__day">7</span> days
</span>
</span>
</p>.deadline-label {
position: relative;
width: 110px;
height: 22px;
overflow: hidden;
color: transparent; /* no direct text — masks handle colour */
}
.deadline-label__mask {
position: absolute;
top: 0; left: 0;
height: 100%;
overflow: hidden;
white-space: nowrap;
}
.deadline-label__mask--white {
width: 100%;
color: var(--color-character);
z-index: 1;
}
.deadline-label__mask--red {
width: 0;
color: var(--color-primary);
background-color: var(--color-bg);
animation: anim-label-wipe var(--duration-cycle) linear infinite;
z-index: 2;
}
/* Inner span is always full label width so text never shifts */
.deadline-label__inner {
display: block;
width: 110px;
text-align: center;
}
@keyframes anim-label-wipe {
from { width: 0; }
to { width: 98%; }
}Step 9 — The Vanilla JS Controller
The JS does three things: push the cycle duration to all animated elements on boot, schedule the writing speed ramp, and run the day countdown. It is wrapped in an IIFE so nothing leaks to the global scope. Every function that creates timers returns a cancel handle — a function that clears all its timeouts or intervals. This makes the loop fully teardown-safe.
(() => {
'use strict';
/* ── DOM refs — resolved once, never queried again ── */
const els = {
progressFill: document.querySelector('.progress-fill'),
deathGroup: document.querySelector('.death-group'),
designerArm: document.getElementById('js-designer-arm'),
flames: [...document.querySelectorAll('.flame')],
maskRed: document.querySelector('.deadline-label__mask--red'),
daySpans: [...document.querySelectorAll('.deadline-label__day')],
};
/* ── applyGlobalDuration ──────────────────────────── */
function applyGlobalDuration() {
const dur = `${CONFIG.cycleDuration}s`;
[els.progressFill, els.deathGroup, els.maskRed].forEach(el => {
el.style.animationDuration = dur;
});
// Flames: update only slot 0 (show/hide), preserve slot 1 (flicker)
els.flames.forEach(el => setAnimationDurationSlot(el, dur, 0));
}
/* ── startDayCountdown ────────────────────────────── */
function startDayCountdown() {
const totalMs = CONFIG.cycleDuration * 1000;
const tickMs = totalMs / CONFIG.totalDays;
let remaining = CONFIG.totalDays;
// Show initial value immediately
els.daySpans.forEach(span => { span.textContent = remaining; });
const id = setInterval(() => {
remaining -= 1;
els.daySpans.forEach(span => { span.textContent = remaining; });
if (remaining === 0) {
clearInterval(id);
// Reset just before the next CSS cycle loops
setTimeout(
() => els.daySpans.forEach(span => { span.textContent = CONFIG.totalDays; }),
tickMs - 50
);
}
}, tickMs);
return () => clearInterval(id);
}
/* ── runCycle ─────────────────────────────────────── */
function runCycle() {
const cancelRamp = scheduleWritingRamp();
const cancelCountdown = startDayCountdown();
return () => { cancelRamp(); cancelCountdown(); };
}
/* ── init ─────────────────────────────────────────── */
function init() {
applyGlobalDuration();
runCycle();
setInterval(runCycle, CONFIG.cycleDuration * 1000);
}
document.readyState === 'loading'
? document.addEventListener('DOMContentLoaded', init)
: init();
})();The CSS animations loop infinitely on their own — they never need to be restarted. Only the JS timers (writing ramp + day countdown) need to repeat. setInterval(runCycle, ...) fires a new runCycle every 20 seconds. Since startDayCountdown clears its own interval when remaining hits 0, and scheduleWritingRamp's timeouts all fire within 15 seconds, there is no overlap. The previous cycle's timers are all finished before the next cycle's runCycle fires. If you shorten cycleDuration below 15 seconds, you would need to store and call the cancel handles from the previous cycle.
Tuning Reference
| Token / Property | Default | Effect |
|---|---|---|
| CONFIG.cycleDuration / --duration-cycle | 20s | Full animation loop. Must match in both JS and CSS. |
| CONFIG.totalDays | 7 | Day count shown in label. Also controls countdown tick interval. |
| CONFIG.writeRamp | see code | Array of [delayMs, duration] pairs controlling designer arm speed ramp. |
| --duration-arm-swing | 1.2s | Death arm pendulum speed. Lower = faster swing. |
| --color-primary | #be002a | Red used for death, progress fill, and label colour-wipe. |
| --color-character | #fefffe | Designer body and arm colour. |
| transform-origin on .death-arm | 0% 50% | Arm shoulder pivot. Change if arm detaches from body after editing SVG. |
| anim-walk keyframe values | see code | X positions of death at each % of cycle. Tune to match your rail width. |
| anim-flame-show 74% | 74% | Point in cycle when fire appears. Change to sync with death's arrival. |
| tickMs formula | cycleDuration * 1000 / totalDays | Each day lasts this many ms. Adjust totalDays to change tick rate. |
Full Source Code
The complete file is a single self-contained HTML document. Save as deadline-loader.html and open in any modern browser. No dependencies, no build step, no internet connection required after the Google Fonts request loads the Oswald typeface.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/deadline-loader.htmlThe arm-scythe sync, the colour-wipe label, the compound animation-duration fix, the pre-built HTML masks, the cancel-handle pattern — these are the details that separate a tutorial worth bookmarking from one you forget.