Back to the library

Ledger — burn-down runway

Variant: Burn-down runway

A budget burn-down that ends on a date, not a percentage. The dashed pace line enters first, eased out like any frame, because spend means nothing until there is a rate to measure it against. Recorded spend then draws linearly — x is days, and an eased pen would claim mid-month went faster than its ends — with dashoffset stops at each segment’s cumulative length so the rate holds across uneven slopes, and the percentage and the day in the header tick on those same stops, so the readout always names the day the pen is on. The projection is arithmetic rather than record, so it does not keep the pen’s pace: one 450ms ease-out throw from today to zero, quick enough to read as computed. Where it lands, a rust tick and the six-day shortfall band appear, and the status swaps to the verdict in place — the old line out in 96ms before the new one arrives, so the two never overprint. Use it for spend caps, credit balances, sprint scope — anywhere "how long until it runs out" is the real question.

datachartprogresssvgfinance

The source

A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Ledger — burn-down runway</title>
    <style>
      @property --_n {
        syntax: '<integer>';
        inherits: true;
        initial-value: 100;
      }
      @property --_d {
        syntax: '<integer>';
        inherits: true;
        initial-value: 0;
      }

      :root {
        /* Neutrals — fixed, never remixed. */
        --ivory: #faf9f5;
        --paper: #ffffff;
        --oat: #e3dacc;
        --slate: #141413;

        /* Knobs — the remix API. A knob exists iff declared here and used below. */
        --hue: 38.8;
        --lift: 0;
        --chroma: 1;
        --speed: 1;

        /* Accents — derived from the knobs with the family's offsets baked in,
           so one hue roll moves them together. Defaults reproduce the original hex. */
        --clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
        --clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
        --olive: oklch(
          calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
        );
        --rust: oklch(
          calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
        );

        /* Clock — 6.4s: the pace line, 1.8s of recorded spend, a 450ms
           projection, then a hold long enough to read the shortfall. */
        --cycle: 6.4s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);

        --_muted: color-mix(in srgb, var(--slate) 52%, transparent);
        --_axis: color-mix(in srgb, var(--slate) 26%, transparent);
        --_pace: color-mix(in srgb, var(--slate) 34%, transparent);
        --_short: color-mix(in srgb, var(--rust) 40%, transparent);
      }

      * {
        box-sizing: border-box;
        margin: 0;
        padding: 0;
      }

      body {
        min-height: 100vh;
        display: grid;
        place-items: center;
        background: var(--ivory);
        font-family:
          system-ui,
          -apple-system,
          'Segoe UI',
          Roboto,
          sans-serif;
        color: var(--slate);
      }

      .chart {
        width: min(300px, 86vw);
        display: grid;
        gap: 8px;
        animation:
          spend var(--t) infinite,
          frame var(--t) infinite;
      }

      .head {
        display: grid;
        grid-template-columns: 2.7em 1fr;
        align-items: baseline;
        column-gap: 10px;
      }

      .label {
        grid-column: 1 / -1;
        margin-bottom: 2px;
        font-size: clamp(11px, 3.2vw, 12px);
        font-weight: 600;
        letter-spacing: 0.14em;
        text-transform: uppercase;
        color: var(--_muted);
      }

      /* The readout is the same pen as the line: it steps through the five
         readings on the draw's own keyframes, so the number and the stroke
         are never at different days. */
      .head {
        font-size: clamp(20px, 6vw, 24px);
      }

      .left {
        font-weight: 600;
        font-variant-numeric: tabular-nums;
        counter-reset: n var(--_n);
      }

      .left::after {
        content: counter(n) '%';
      }

      /* Two statuses in one cell: the honest one while spend is still being
         recorded, the verdict once the projection reaches zero. */
      .status {
        display: grid;
        align-self: center;
        font-size: clamp(11px, 3.2vw, 12px);
        font-weight: 600;
      }

      .status span {
        grid-area: 1 / 1;
      }

      .day {
        color: var(--_muted);
        counter-reset: d var(--_d);
        animation: say-day var(--t) infinite;
      }

      .day::after {
        content: 'Day ' counter(d) ' of 30';
      }

      .out {
        color: var(--rust);
        opacity: 0;
        animation: say-out var(--t) infinite;
      }

      svg {
        display: block;
        width: 100%;
        height: auto;
        overflow: visible;
      }

      .axis {
        stroke: var(--_axis);
        stroke-width: 1;
      }

      .pace {
        fill: none;
        stroke: var(--_pace);
        stroke-width: 1.4;
        stroke-dasharray: 4 4;
      }

      .pace {
        clip-path: inset(-3px 100% -3px -3px);
        animation: wipe-pace var(--t) infinite;
      }

      /* Linear, segment by segment: x is days, and an eased draw would claim
         the middle of the month went faster than its ends. The offsets are the
         five segments' cumulative lengths, so the pen keeps a constant rate in
         x even though the slopes differ. */
      .spend {
        fill: none;
        stroke: var(--clay);
        stroke-width: 2.4;
        stroke-linecap: round;
        stroke-linejoin: round;
        stroke-dasharray: 1;
        stroke-dashoffset: 1;
        animation: draw var(--t) infinite;
      }

      .proj {
        fill: none;
        stroke: var(--clay-d);
        stroke-width: 1.6;
        stroke-dasharray: 2 4;
        stroke-linecap: round;
        clip-path: inset(-3px 100% -3px -3px);
        animation: wipe-proj var(--t) infinite;
      }

      .today {
        fill: var(--clay-d);
        transform-origin: center;
        transform-box: fill-box;
        animation: today var(--t) infinite;
      }

      .short,
      .zero {
        animation: verdict var(--t) infinite;
      }

      .short {
        fill: var(--_short);
      }

      .zero {
        stroke: var(--rust);
        stroke-width: 2;
        stroke-linecap: round;
      }

      .ticks {
        position: relative;
        height: 1.4em;
        font-size: clamp(11px, 3vw, 12px);
        color: var(--_muted);
      }

      .ticks span {
        position: absolute;
        top: 0;
      }

      .ticks .mid {
        left: 50%;
        transform: translateX(-50%);
      }

      .ticks .end {
        right: 0;
      }

      /* Spend and date, in the readout: the five readings on the draw's own
         stops, so the header names the day the pen is on. */
      @keyframes spend {
        0%,
        12% {
          --_n: 100;
          --_d: 0;
          animation-timing-function: linear;
        }
        17.6% {
          --_n: 90;
          --_d: 3;
          animation-timing-function: linear;
        }
        23.2% {
          --_n: 77;
          --_d: 6;
          animation-timing-function: linear;
        }
        28.8% {
          --_n: 63;
          --_d: 9;
          animation-timing-function: linear;
        }
        34.4% {
          --_n: 51;
          --_d: 12;
          animation-timing-function: linear;
        }
        40%,
        93% {
          --_n: 38;
          --_d: 15;
        }
        94%,
        100% {
          --_n: 100;
          --_d: 0;
        }
      }

      @keyframes draw {
        0%,
        12% {
          stroke-dashoffset: 1;
          animation-timing-function: linear;
        }
        17.6% {
          stroke-dashoffset: 0.8057;
          animation-timing-function: linear;
        }
        23.2% {
          stroke-dashoffset: 0.6043;
          animation-timing-function: linear;
        }
        28.8% {
          stroke-dashoffset: 0.4002;
          animation-timing-function: linear;
        }
        34.4% {
          stroke-dashoffset: 0.2014;
          animation-timing-function: linear;
        }
        40%,
        93% {
          stroke-dashoffset: 0;
        }
        94%,
        100% {
          stroke-dashoffset: 1;
        }
      }

      /* Wipes are clip-paths on the strokes themselves rather than animated
         clipPath children, which some engines fail to repaint.
         The pace line is the frame — it has to exist before spend can be
         measured against it — so it enters first, eased out like any frame. */
      @keyframes wipe-pace {
        0%,
        4% {
          clip-path: inset(-3px 100% -3px -3px);
          animation-timing-function: var(--ease-out);
        }
        12%,
        93% {
          clip-path: inset(-3px);
        }
        94%,
        100% {
          clip-path: inset(-3px 100% -3px -3px);
        }
      }

      /* The projection is arithmetic, not record, so it does not keep the
         pen's rate: one eased 450ms throw from today to zero, quick enough to
         read as computed rather than observed. */
      @keyframes wipe-proj {
        0%,
        42% {
          clip-path: inset(-3px 100% -3px -3px);
          animation-timing-function: var(--ease-out);
        }
        49%,
        93% {
          clip-path: inset(-3px);
        }
        94%,
        100% {
          clip-path: inset(-3px 100% -3px -3px);
        }
      }

      @keyframes today {
        0%,
        40% {
          opacity: 0;
          transform: scale(0.6);
          animation-timing-function: var(--ease-out);
        }
        43%,
        93% {
          opacity: 1;
          transform: scale(1);
        }
        94%,
        100% {
          opacity: 0;
          transform: scale(0.6);
        }
      }

      @keyframes verdict {
        0%,
        48% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        52%,
        93% {
          opacity: 1;
        }
        94%,
        100% {
          opacity: 0;
        }
      }

      @keyframes say-day {
        0%,
        48% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        49.5%,
        94% {
          opacity: 0;
        }
        95%,
        100% {
          opacity: 1;
        }
      }

      @keyframes say-out {
        0%,
        49.5% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        52%,
        93% {
          opacity: 1;
        }
        94%,
        100% {
          opacity: 0;
        }
      }

      @keyframes today-rm {
        0%,
        40% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        43%,
        93% {
          opacity: 1;
        }
        94%,
        100% {
          opacity: 0;
        }
      }

      @keyframes frame {
        0% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        4%,
        88% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        92%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* Recorded spend still draws — it is the series — and the verdict
           still lands, but the pace line simply stands, the today dot fades
           rather than pops, and the loop stretches to 11.5s. Every animation
           scales by the same 1.8, so the readout and the stroke stay on the
           same day. */
        .chart,
        .spend,
        .proj,
        .short,
        .zero,
        .day,
        .out {
          animation-duration: calc(var(--t) * 1.8);
        }

        .pace {
          animation: none;
          clip-path: none;
        }

        .today {
          transform: none;
          animation: today-rm calc(var(--t) * 1.8) infinite;
        }
      }
    </style>
  </head>
  <body>
    <div
      class="chart"
      role="img"
      aria-label="A budget burn-down: a dashed pace line falls from 100 to zero over 30 days, spend is drawn for 15 days and finishes at 38 percent, below pace, and a projection from today reaches zero on day 24 — six days before the period ends"
    >
      <div class="head">
        <span class="label">Budget left</span>
        <span class="left"></span>
        <span class="status">
          <span class="day"></span>
          <span class="out">Runs out day 24</span>
        </span>
      </div>
      <svg viewBox="-4 0 308 120" xmlns="http://www.w3.org/2000/svg">
        <rect class="short" x="241.9" y="106" width="58.1" height="6" />
        <line class="axis" x1="-4" y1="112" x2="304" y2="112" />
        <path class="pace" d="M0 8 L300 112" />
        <path class="proj" d="M150 72.48 L241.9 112" />
        <path
          class="spend"
          pathLength="1"
          d="M0 8 L30 18.4 L60 31.92 L90 46.48 L120 58.96 L150 72.48"
        />
        <line class="zero" x1="241.9" y1="106" x2="241.9" y2="116" />
        <circle class="today" cx="150" cy="72.48" r="3.6" />
      </svg>
      <div class="ticks">
        <span>Start</span>
        <span class="mid">Today</span>
        <span class="end">Day 30</span>
      </div>
    </div>
  </body>
</html>

The variant family

Same idea, one axis moved. Compare them side by side before you commit.