Back to the library

Chart — line draw

Variant: Line

A series writing itself left to right in 1.6s, and the one animation in the set that is linear throughout: a time axis is traversed at a constant rate, so easing the pen would say the data sped up through the middle of the range. The marker rides the same clock parameterised by cumulative path length rather than by x, which is what welds it to the end of the stroke — an x-parameterised tip runs a visible step ahead on the steep segments. The area fill waits until the line lands, because an area is a claim about a completed range.

datachartentrancesvgloop

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 — line draw</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);

        /* Clock — 5.2s, of which the pen owns 1.6s. Longer than any UI response
           should be, which is the honest reading: this is a chart being written,
           not a control answering a press. */
        --cycle: 5.2s;
        --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);
      }

      .tile {
        width: min(320px, 90vw);
        display: grid;
        gap: 12px;
        padding: 18px 22px 20px;
        border: 1px solid var(--oat);
        border-radius: calc(14px * var(--round));
        background: var(--paper);
      }

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

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

      /* Frame before data: the axis and the gridline are already there when the
         pen starts and stay when it clears, so nothing the reader measures
         against is ever in motion. */
      .axis {
        fill: var(--oat);
      }

      .grid {
        stroke: var(--oat);
        stroke-width: 1;
        stroke-dasharray: 3 4;
      }

      .line {
        fill: none;
        stroke: var(--clay-d);
        stroke-width: 2.5;
        stroke-linecap: round;
        stroke-linejoin: round;
        /* pathLength="1" normalises the geometry, so the dash carries no pixel
           lengths and the tip below can be parameterised the same way. */
        stroke-dasharray: 1;
        animation: draw var(--t) linear infinite;
      }

      /* Linear, and it is the only thing in the file that is: a time series is
         traversed at a constant rate, so easing the pen would claim the data
         sped up through the middle of the range. */
      @keyframes draw {
        0%,
        8.08% {
          stroke-dashoffset: 1;
        }
        38.85%,
        93% {
          stroke-dashoffset: 0;
        }
        93.5%,
        100% {
          stroke-dashoffset: 1;
        }
      }

      .tip circle {
        fill: var(--clay-d);
      }

      .tip .halo {
        fill: var(--paper);
      }

      /* The pen tip. Its stops are placed by cumulative path length rather than
         by x, which is what keeps it welded to the end of the stroke — on the
         steep segments an x-parameterised marker runs a visible step ahead. */
      .tip {
        animation: trace var(--t) linear infinite;
      }

      @keyframes trace {
        0%,
        8.08% {
          transform: translate(0px, 0px);
        }
        13.06% {
          transform: translate(44px, -14px);
        }
        17.86% {
          transform: translate(88px, -7px);
        }
        23.53% {
          transform: translate(132px, -36px);
        }
        28.32% {
          transform: translate(176px, -30px);
        }
        33.94% {
          transform: translate(220px, -58px);
        }
        38.85%,
        93% {
          transform: translate(264px, -70px);
        }
        93.5%,
        100% {
          transform: translate(0px, 0px);
        }
      }

      /* The fill is a claim about a whole range, so it cannot precede the line
         that establishes the range. It arrives in 250ms once the pen lands. */
      .area {
        fill: var(--clay);
        opacity: 0;
        animation: area-in var(--t) linear infinite;
      }

      @keyframes area-in {
        0%,
        38.85% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        43.6%,
        93% {
          opacity: 0.16;
        }
        93.5%,
        100% {
          opacity: 0;
        }
      }

      /* The re-arm window. Everything resets at 93.5%, five percent into a dead
         frame, so the pen is back at the origin before the ink fades up again
         and the loop never plays backwards. */
      .ink {
        animation: ink-cycle var(--t) linear infinite;
      }

      @keyframes ink-cycle {
        0%,
        88% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        93%,
        96% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        100% {
          opacity: 1;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The series is simply there, drawn and complete; only the summary
           breathes, on a cycle slow enough to read as weather. */
        .line {
          animation: none;
          stroke-dashoffset: 0;
        }

        .tip {
          animation: none;
          transform: translate(264px, -70px);
        }

        .ink {
          animation: none;
          opacity: 1;
        }

        .area {
          animation: area-breathe calc(var(--t) * 1.6) var(--ease-out) infinite;
        }

        @keyframes area-breathe {
          0%,
          38.85% {
            opacity: 0.04;
          }
          43.6%,
          93% {
            opacity: 0.16;
          }
          93.5%,
          100% {
            opacity: 0.04;
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="tile">
      <span class="label">Requests / min</span>
      <svg
        viewBox="0 0 288 152"
        role="img"
        aria-label="A line chart drawing itself left to right, with a marker at the pen tip and an area fill that arrives once the line lands"
      >
        <path class="grid" d="M0 79 L288 79" />
        <g class="ink">
          <path
            class="area"
            d="M12 100 L56 86 L100 93 L144 64 L188 70 L232 42 L276 30 L276 128 L12 128 Z"
          />
          <path
            class="line"
            pathLength="1"
            d="M12 100 L56 86 L100 93 L144 64 L188 70 L232 42 L276 30"
          />
          <g class="tip">
            <circle class="halo" cx="12" cy="100" r="6.5" />
            <circle cx="12" cy="100" r="4" />
          </g>
        </g>
        <rect class="axis" x="0" y="128" width="288" height="1.5" />
      </svg>
    </div>
  </body>
</html>

The variant family

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