Back to the library

Chart entrance — bars from the baseline

Variant: Bars from the baseline

The frame lands before any datum does: the axis rules itself out from the left, and only then do six bars grow up off it, 60ms apart. That order is the whole argument — a bar’s length means nothing until there is a baseline to measure it from, so a chart that fades in whole spends its first 400ms showing marks with no scale under them. The bars leave at 6% rather than zero, because a column rising off the axis should read as a thing that was already there. The cascade is the flourish and the growth is the data, which is why reduced motion keeps the second and drops the first.

datachartentrancestagger

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>Chart entrance — bars from the baseline</title>
    <style>
      :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)
        );

        --_muted: color-mix(in srgb, var(--slate) 45%, transparent);
        --_grid: color-mix(in srgb, var(--oat) 78%, transparent);
        --_axis: color-mix(in srgb, var(--slate) 26%, transparent);

        /* Clock — 5.4s: 216ms for the frame, a 300ms stagger over six bars,
           421ms of growth each, then a long settled hold. A chart entrance is
           mostly hold; the reveal is a fifth of the loop. */
        --cycle: 5.4s;
        --t: calc(var(--cycle) / var(--speed));

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

      * {
        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(288px, 88vw);
        display: grid;
        gap: 10px;
        animation: frame var(--t) infinite;
      }

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

      .plot {
        position: relative;
        height: clamp(96px, 27vw, 118px);
      }

      /* The frame lands before any datum does. A bar's length only means
         something measured against a baseline, so the baseline has to exist
         first — otherwise the first 400ms are marks with no scale under them. */
      .grid {
        position: absolute;
        inset: 0;
        display: grid;
        grid-template-rows: repeat(3, 1fr);
      }

      .grid span {
        border-top: 1px solid var(--_grid);
      }

      .axis {
        position: absolute;
        left: 0;
        right: 0;
        bottom: 0;
        height: 1px;
        background: var(--_axis);
        transform-origin: left center;
        animation: rule var(--t) infinite;
      }

      .bars {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: flex-end;
        gap: clamp(5px, 1.8vw, 8px);
      }

      .bar {
        flex: 1;
        height: var(--_h);
        background: var(--clay);
        border-radius: calc(2px * var(--round)) calc(2px * var(--round)) 0 0;
        transform-origin: bottom center;
        /* Matches the 0% keyframe so the bars sit flat on the axis during the
           delay rather than snapping down when their animation picks up. */
        transform: scaleY(0.06);
        animation: grow var(--t) var(--ease-out) infinite;
        animation-delay: calc((0.36s + var(--_i) * 0.06s) / var(--speed));
      }

      .bar.now {
        background: var(--clay-d);
      }

      .days {
        display: flex;
        gap: clamp(5px, 1.8vw, 8px);
        font-size: clamp(10px, 3vw, 11px);
        color: var(--_muted);
      }

      .days span {
        flex: 1;
        text-align: center;
      }

      /* 6% is a sliver, not zero — the bar leaves the axis as a thing that was
         already there. It collapses again at 85-87%, four hundred milliseconds
         after the chart has faded to nothing, so the re-arm is never on screen. */
      @keyframes grow {
        0% {
          transform: scaleY(0.06);
        }
        7.8%,
        85% {
          transform: scaleY(1);
        }
        87%,
        100% {
          transform: scaleY(0.06);
        }
      }

      @keyframes rule {
        0% {
          transform: scaleX(0);
          animation-timing-function: var(--ease-out);
        }
        6%,
        100% {
          transform: scaleX(1);
        }
      }

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

      @media (prefers-reduced-motion: reduce) {
        /* The growth stays: a bar's length is the value, and a chart that never
           draws its values makes no claim at all. The cascade goes — that is the
           flourish, not the data — and the whole loop stretches to 9.2s. Because
           every duration and delay scales by the same 1.7, the collapse still
           lands inside the dark window and nothing rewinds on screen. */
        .chart,
        .bar {
          animation-duration: calc(var(--t) * 1.7);
        }

        .bar {
          animation-delay: calc(0.612s / var(--speed));
        }

        .axis {
          animation: none;
          transform: scaleX(1);
        }
      }
    </style>
  </head>
  <body>
    <div
      class="chart"
      role="img"
      aria-label="A bar chart arriving: the baseline and gridlines draw first, then six bars grow up from the axis one after another"
    >
      <span class="label">Deploys / day</span>
      <div class="plot">
        <div class="grid"><span></span><span></span><span></span></div>
        <div class="bars">
          <div class="bar" style="--_h: 38%; --_i: 0"></div>
          <div class="bar" style="--_h: 61%; --_i: 1"></div>
          <div class="bar" style="--_h: 47%; --_i: 2"></div>
          <div class="bar" style="--_h: 79%; --_i: 3"></div>
          <div class="bar" style="--_h: 68%; --_i: 4"></div>
          <div class="bar now" style="--_h: 92%; --_i: 5"></div>
        </div>
        <div class="axis"></div>
      </div>
      <div class="days">
        <span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span>
      </div>
    </div>
  </body>
</html>