Back to the library

Ledger — reallocation

Variant: Reallocation

A fixed total re-split three ways, built so that conservation is structural: the state is two boundaries, each segment is the span between them, and a share can only grow by taking from a neighbour. Each boundary is a translated full-width layer rather than a resized segment, so nothing reflows. Transfers take 480ms of ease-in-out — the bar is already on screen and being read, so a boundary should leave gently and settle rather than arrive — and then the split stands for 1.92s, long enough to read the new shares, which tick on the same stops. The first transfer moves both boundaries at once and slides Hiring wholesale, the honest picture of money passing through a category it does not touch. The segments are neutral steps so colour can mean what it means across the family: for a second after each move the giving share inks clay and the receiving one olive, which is what keeps that pass-through legible. A one-line journal crossfades with each move, and the third transfer returns the split to its opening, so the loop has no reset to hide. Under reduced motion the boundaries cut rather than slide and the inks carry the direction. Reach for it for budget planners, resource sliders, and portfolio rebalancing.

datachartlayoutloopfinance

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 — reallocation</title>
    <style>
      /* Two boundaries are the whole state. Each segment is the span between
         them, so a share can only grow by taking from a neighbour — the total
         is fixed by construction, not by careful keyframing. */
      @property --_b1 {
        syntax: '<number>';
        inherits: true;
        initial-value: 45;
      }
      @property --_b2 {
        syntax: '<number>';
        inherits: true;
        initial-value: 80;
      }
      /* The labels' integers ride the same stops, so a readout never shows a
         share the bar is not drawing. */
      @property --_n1 {
        syntax: '<integer>';
        inherits: true;
        initial-value: 45;
      }
      @property --_n2 {
        syntax: '<integer>';
        inherits: true;
        initial-value: 80;
      }

      :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;
        --round: 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 — 7.2s: three transfers of 480ms, each followed by 1.92s of
           standing still. The third returns the split to where it began, so
           the loop needs no fade to hide a reset. */
        --cycle: 7.2s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);

        --_muted: color-mix(in srgb, var(--slate) 52%, transparent);
        /* Categories are neutral steps so colour is free to mean what it means
           across the family: clay-d gives, olive receives. */
        --_c1: color-mix(in srgb, var(--slate) 72%, var(--paper));
        --_c2: color-mix(in srgb, var(--slate) 46%, var(--paper));
        --_c3: color-mix(in srgb, var(--slate) 24%, var(--paper));
      }

      * {
        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);
      }

      .card {
        width: min(300px, 86vw);
        display: grid;
        gap: 12px;
        animation: split var(--t) infinite;
      }

      .head {
        display: flex;
        justify-content: space-between;
        align-items: baseline;
        font-size: clamp(11px, 3.2vw, 12px);
      }

      .label {
        font-weight: 600;
        letter-spacing: 0.14em;
        text-transform: uppercase;
        color: var(--_muted);
      }

      .total {
        font-weight: 600;
        font-variant-numeric: tabular-nums;
      }

      /* Three full-width layers stacked in order, each slid right to its own
         starting boundary. Moving a boundary is a translate, never a width, so
         the bar is composited end to end and nothing reflows. */
      .bar {
        position: relative;
        height: clamp(22px, 6.4vw, 28px);
        border-radius: calc(6px * var(--round));
        overflow: hidden;
        clip-path: inset(0 round calc(6px * var(--round)));
        background: var(--_c1);
      }

      .seg {
        position: absolute;
        inset: 0;
        border-left: 2px solid var(--ivory);
      }

      .seg.s2 {
        background: var(--_c2);
        transform: translateX(calc(var(--_b1) * 1%));
      }

      .seg.s3 {
        background: var(--_c3);
        transform: translateX(calc(var(--_b2) * 1%));
      }

      .legend {
        display: grid;
        grid-template-columns: repeat(3, auto);
        justify-content: space-between;
        font-size: clamp(11px, 3.2vw, 12px);
      }

      .key {
        display: flex;
        align-items: center;
        gap: 6px;
        color: var(--_muted);
      }

      .key i {
        width: 8px;
        height: 8px;
        border-radius: 50%;
        background: var(--_c);
      }

      .key b {
        font-weight: 600;
        color: var(--slate);
        font-variant-numeric: tabular-nums;
        min-width: 3ch;
        text-align: right;
      }

      /* The giving share inks clay-d and the receiving one olive for the
         second after each transfer, so the direction is legible even when a
         move slides a segment it does not touch. */
      .k1 b {
        counter-reset: v var(--_n1);
        animation: key-1 var(--t) infinite;
      }

      .k2 b {
        counter-reset: v calc(var(--_n2) - var(--_n1));
        animation: key-2 var(--t) infinite;
      }

      .k3 b {
        counter-reset: v calc(100 - var(--_n2));
        animation: key-3 var(--t) infinite;
      }

      .key b::after {
        content: counter(v) '%';
      }

      /* The journal: one line per transfer, crossfading in place. Arrival is
         eased out over 180ms and departure is a quicker 120ms, so for a frame
         neither line is at full ink and the two never read as one. */
      .log {
        display: grid;
        font-size: clamp(11px, 3.2vw, 12px);
        color: var(--_muted);
        border-top: 1px solid var(--oat);
        padding-top: 10px;
      }

      .log span {
        grid-area: 1 / 1;
        opacity: 0;
        animation: var(--_k) var(--t) infinite;
      }

      .log b {
        font-weight: 600;
        color: var(--slate);
      }

      /* Ease-in-out: the bar is already on screen and being read, so a
         boundary leaves gently, crosses, and settles — the curve for something
         travelling from A to B rather than arriving. */
      @keyframes split {
        0%,
        20% {
          --_b1: 45;
          --_b2: 80;
          --_n1: 45;
          --_n2: 80;
          animation-timing-function: var(--ease-in-out);
        }
        26.67%,
        53.33% {
          --_b1: 55;
          --_b2: 90;
          --_n1: 55;
          --_n2: 90;
          animation-timing-function: var(--ease-in-out);
        }
        60%,
        86.67% {
          --_b1: 55;
          --_b2: 80;
          --_n1: 55;
          --_n2: 80;
          animation-timing-function: var(--ease-in-out);
        }
        93.33%,
        100% {
          --_b1: 45;
          --_b2: 80;
          --_n1: 45;
          --_n2: 80;
        }
      }

      @keyframes log-1 {
        0%,
        20% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        22.5%,
        53.33% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        55%,
        100% {
          opacity: 0;
        }
      }

      @keyframes log-2 {
        0%,
        53.33% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        55.83%,
        86.67% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        88.33%,
        100% {
          opacity: 0;
        }
      }

      @keyframes log-3 {
        0%,
        20% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        21.67%,
        86.67% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        89.17%,
        100% {
          opacity: 1;
        }
      }

      @keyframes key-1 {
        0%,
        2% {
          color: var(--clay-d);
        }
        5%,
        20% {
          color: var(--slate);
        }
        22%,
        40% {
          color: var(--olive);
        }
        43%,
        86.67% {
          color: var(--slate);
        }
        88.67%,
        100% {
          color: var(--clay-d);
        }
      }

      @keyframes key-2 {
        0%,
        2% {
          color: var(--olive);
        }
        5%,
        53.33% {
          color: var(--slate);
        }
        55.33%,
        73.33% {
          color: var(--clay-d);
        }
        76.33%,
        86.67% {
          color: var(--slate);
        }
        88.67%,
        100% {
          color: var(--olive);
        }
      }

      @keyframes key-3 {
        0%,
        20% {
          color: var(--slate);
        }
        22%,
        40% {
          color: var(--clay-d);
        }
        43%,
        53.33% {
          color: var(--slate);
        }
        55.33%,
        73.33% {
          color: var(--olive);
        }
        76.33%,
        100% {
          color: var(--slate);
        }
      }

      /* Reduced motion: the same four states, cut rather than slid. */
      @keyframes split-rm {
        0%,
        20% {
          --_b1: 45;
          --_b2: 80;
          --_n1: 45;
          --_n2: 80;
        }
        26.67%,
        53.33% {
          --_b1: 55;
          --_b2: 90;
          --_n1: 55;
          --_n2: 90;
        }
        60%,
        86.67% {
          --_b1: 55;
          --_b2: 80;
          --_n1: 55;
          --_n2: 80;
        }
        93.33%,
        100% {
          --_b1: 45;
          --_b2: 80;
          --_n1: 45;
          --_n2: 80;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The boundaries cut instead of sliding, landing at the end of each
           transfer window, and the journal crossfade and the clay/olive inks
           carry the direction on their own, all on a 12.96s loop. */
        .card {
          animation: split-rm calc(var(--t) * 1.8) steps(1, end) infinite;
        }

        .log span,
        .key b {
          animation-duration: calc(var(--t) * 1.8);
        }
      }
    </style>
  </head>
  <body>
    <div
      class="card"
      role="img"
      aria-label="A stacked budget bar with a fixed total of 120k split between Infra, Hiring and Events. Twelve thousand dollars moves from Events to Infra, then from Hiring to Events, then from Infra back to Hiring; each boundary slides and the shares update while the total never changes"
    >
      <div class="head">
        <span class="label">Q4 budget</span>
        <span class="total">$120k</span>
      </div>
      <div class="bar">
        <div class="seg s2"></div>
        <div class="seg s3"></div>
      </div>
      <div class="legend">
        <span class="key k1" style="--_c: var(--_c1)"><i></i>Infra <b></b></span>
        <span class="key k2" style="--_c: var(--_c2)"><i></i>Hiring <b></b></span>
        <span class="key k3" style="--_c: var(--_c3)"><i></i>Events <b></b></span>
      </div>
      <div class="log">
        <span style="--_k: log-1"><b>$12k</b> Events → Infra</span>
        <span style="--_k: log-2"><b>$12k</b> Hiring → Events</span>
        <span style="--_k: log-3"><b>$12k</b> Infra → Hiring</span>
      </div>
    </div>
  </body>
</html>

The variant family

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