Aduok Code

Deadline Loader — Animated Progress Bar with Death Character & Countdown

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.

What you will learn

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.

01
Part One
Design Tokens

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.

CSS
: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;
}
JavaScript
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'],
  ],
});
Why Object.freeze()?

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.

02
Part Two
HTML Structure

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.

HTML
<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>
ElementRoleAnimated
.progress-fillRed <rect> clipped to rail shape — sweeps left to rightYes — x from −100% to −3%
.death-groupDeath body + arm + scythe group — walks across the barYes — translateX 0 → 520px
.death-armArm path — pivots from shoulder (transform-origin: 0% 50%)Yes — rotate −25° ↔ 20°
.death-toolScythe — same keyframe as arm, moves as one unitYes — same as death-arm
.designer-arm-groupWriting arm + pen — JS ramps speed over cycleYes — rotate 0° ↔ −10°, speed ramps
.flameThree flame layers — hidden until 74% of cycleYes — opacity + scale flicker
.deadline-label__mask--redRed text overlay — width grows from 0 to 98%Yes — width 0 → 98%
.deadline-label__mask--whiteWhite text baseline — always full width behind red maskNo
.deadline-label__dayDay number span — updated by JS countdownJS — textContent 7 → 0
03
Part Three
Progress Fill

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.

CSS
.progress-fill {
  animation: anim-progress var(--duration-cycle) linear infinite;
}

@keyframes anim-progress {
  from { x: -100%; }
  to   { x: -3%;   }
}
Why x: −3% instead of 0%?

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.

04
Part Four
Death Walks

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.

CSS
.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); }
}
05
Part Five
Arm & Scythe Swing

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.

CSS
/* 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); }
}
SettingWithout fixWith fix
transform-boxcontent-box (default)fill-box
transform-originRelative to SVG viewport (0,0)Relative to element bounding box
transform-origin: 0% 50%Top-left corner of SVG imageLeft-center of the arm path (shoulder)
Rotation resultElement flies far off-screenElement pivots naturally at shoulder
06
Part Six
Designer Writing Arm

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.

CSS
.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);   }
}
JavaScript
/**
 * 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);
}
07
Part Seven
Flames

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.

CSS
.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);   }
}
Updating compound animation-duration from JS

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.

JavaScript
/**
 * 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(', ');
}
08
Part Eight
Colour-Wipe Label

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.

HTML
<!-- 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>
CSS
.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%; }
}
09
Part Nine
Vanilla JS Controller

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.

JavaScript
(() => {
  '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();
})();
Why does init() not cancel the previous cycle before starting the next?

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 / PropertyDefaultEffect
CONFIG.cycleDuration / --duration-cycle20sFull animation loop. Must match in both JS and CSS.
CONFIG.totalDays7Day count shown in label. Also controls countdown tick interval.
CONFIG.writeRampsee codeArray of [delayMs, duration] pairs controlling designer arm speed ramp.
--duration-arm-swing1.2sDeath arm pendulum speed. Lower = faster swing.
--color-primary#be002aRed used for death, progress fill, and label colour-wipe.
--color-character#fefffeDesigner body and arm colour.
transform-origin on .death-arm0% 50%Arm shoulder pivot. Change if arm detaches from body after editing SVG.
anim-walk keyframe valuessee codeX 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 formulacycleDuration * 1000 / totalDaysEach 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.

HTML — deadline-loader.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/deadline-loader.html

The 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.

Want to see it in action?Watch the full build on YouTube