Back to the library

Scroll — progress rail and collapsing header

Variant: Progress rail

Scroll-linked rather than scroll-triggered, which is the distinction the whole family turns on: this is not an animation that a scroll starts, it is a reading of where the scroll currently is. One registered number is the position, and the page’s travel, the rail’s width, the percentage and the header’s collapse are all pure functions of it — so the rail physically cannot be at 60% while the page is at 58, and the return throw runs the readout backwards exactly as it ran forwards. Nothing linked may be eased on its own; the rail moves at the hand’s speed because it is reporting the hand. The collapse is clamped to the first 26px of travel and flat outside it, since a header that keeps responding after it has finished collapsing never finishes. The large title is not animated at all — it is content, and it leaves the way content leaves, under the bar. And scaleX(0) is normally forbidden in this library but is the only correct rest state here: the rail is a measurement, not an element arriving, and no progress has to look like no progress.

scrolldataprogressstatus

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>Scroll — progress rail and collapsing header</title>
    <style>
      /* One registered number is the scroll position, and everything the chrome
         reports is a pure function of it: the page's travel, the rail's width,
         and the header's collapse. Nothing here is on a timeline of its own, so
         the rail cannot be at 60% while the page is at 58 — there is no second
         clock to drift against. */
      @property --_p {
        syntax: '<number>';
        initial-value: 0;
        inherits: true;
      }

      /* The readout's integer twin. It is animated on the same stops in the same
         block, which is the only reason the number and the rail agree. */
      @property --_pct {
        syntax: '<integer>';
        initial-value: 0;
        inherits: true;
      }

      :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 — every duration in the document runs off --t. */
        --cycle: 6s;
        --t: calc(var(--cycle) / var(--speed));

        /* The flick: thrown, then coasting against friction. */
        --ease-accent: cubic-bezier(0.14, 0.78, 0.28, 1);

        /* Scrollable overflow in px, unitless so the same number can feed a
           translation and a ratio without a second constant to keep in step. */
        --_span: 129;
      }

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

      .rig {
        width: min(190px, 86vw);
        display: grid;
        gap: 10px;
        justify-items: center;
        animation: read var(--t) infinite;
        /* The collapse fraction: scroll position in px, offset and divided by
           the band it is allowed to act over, clamped at both ends. */
        --_c: clamp(0, calc((var(--_p) * var(--_span) - 6) / 26), 1);
      }

      .cap {
        font-size: 11px;
        letter-spacing: 0.04em;
        color: color-mix(in srgb, var(--slate) 52%, transparent);
      }

      .frame {
        position: relative;
        width: 100%;
        height: 152px;
        overflow: hidden;
        background: var(--paper);
        border: 1.5px solid var(--oat);
        border-radius: calc(12px * var(--round));
      }

      .view {
        position: absolute;
        inset: 0;
        z-index: 1;
        overflow: hidden;
      }

      .page {
        padding: 30px 13px 14px;
        display: grid;
        gap: 14px;
        transform: translateY(calc(var(--_p) * var(--_span) * -1px));
      }

      /* The large title is not animated by anything. It is content, and it
         leaves the way content leaves — under the bar. Cross-fading it out
         would claim it went somewhere, and it did not. */
      .hero {
        height: 26px;
        font-size: 18px;
        line-height: 26px;
        letter-spacing: -0.01em;
        font-weight: 600;
      }

      .block {
        display: grid;
        gap: 9px;
      }

      .block i {
        display: block;
        height: 5px;
        border-radius: 999px;
        background: color-mix(in srgb, var(--slate) 13%, transparent);
      }

      .block i:last-child {
        width: 58%;
      }

      .bar {
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        z-index: 2;
        height: 30px;
        display: flex;
        align-items: center;
        gap: 8px;
        padding: 0 11px;
        background: var(--paper);
      }

      .mark {
        flex: none;
        width: 9px;
        height: 9px;
        border-radius: calc(3px * var(--round));
        background: var(--clay);
      }

      /* The collapse is clamped to the first 26px of travel and flat outside it:
         a header that keeps responding after it has finished collapsing is a
         header that never finishes. */
      .compact {
        flex: 1;
        font-size: 12px;
        font-weight: 600;
        letter-spacing: -0.01em;
        opacity: var(--_c);
        transform: translateY(calc((1 - var(--_c)) * 7px));
      }

      .pct {
        font-size: 11px;
        font-variant-numeric: tabular-nums;
        letter-spacing: 0.02em;
        color: color-mix(in srgb, var(--slate) 52%, transparent);
        counter-reset: pct var(--_pct);
      }

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

      .hair {
        position: absolute;
        left: 0;
        right: 0;
        bottom: 0;
        height: 1px;
        background: var(--oat);
        opacity: var(--_c);
      }

      /* scaleX(0) is normally forbidden in this library, and here it is the only
         correct rest state: the rail is a measurement rather than an element
         arriving, and no progress has to look like no progress. */
      .rail {
        position: absolute;
        left: 0;
        right: 0;
        bottom: 0;
        height: 2px;
        background: var(--clay);
        transform-origin: left center;
        transform: scaleX(var(--_p));
      }

      /* Three flicks down and one long throw back to the top. The loop ends
         where it started, so the piece needs no dark window to reset in — and
         the return is the part worth watching, because a scroll-linked readout
         has to run backwards exactly as it ran forwards. Ease the rail
         separately and it would arrive somewhere the page is not. */
      @keyframes read {
        0%,
        10% {
          --_p: 0;
          --_pct: 0;
          animation-timing-function: var(--ease-accent);
        }
        17%,
        29% {
          --_p: 0.32;
          --_pct: 32;
          animation-timing-function: var(--ease-accent);
        }
        36%,
        48% {
          --_p: 0.66;
          --_pct: 66;
          animation-timing-function: var(--ease-accent);
        }
        55%,
        68% {
          --_p: 1;
          --_pct: 100;
          animation-timing-function: var(--ease-accent);
        }
        80%,
        100% {
          --_p: 0;
          --_pct: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* Parked two thirds down, header collapsed, rail and readout stating the
           position they actually describe. The rail keeps breathing because it
           is the one mark here that reports something live. */
        .rig {
          animation: none;
          --_p: 0.66;
          --_pct: 66;
        }

        .rail {
          animation: rest-rail calc(var(--t) * 0.5) ease-in-out infinite;
        }

        @keyframes rest-rail {
          0%,
          100% {
            opacity: 0.45;
          }
          50% {
            opacity: 1;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="rig"
      role="img"
      aria-label="A page flicked down three times: a rail across the bottom of the app bar grows with the scroll position, a percentage counts with it, the large title slides under the bar and a compact one fades in, and the whole reading runs backwards when the page is thrown back to the top"
    >
      <p class="cap">position, not time</p>
      <div class="frame">
        <div class="view">
          <div class="page">
            <p class="hero">Release notes</p>
            <div class="block"><i></i><i></i><i></i><i></i><i></i></div>
            <div class="block"><i></i><i></i><i></i><i></i><i></i></div>
            <div class="block"><i></i><i></i><i></i><i></i></div>
          </div>
        </div>
        <div class="bar">
          <span class="mark"></span>
          <span class="compact">Release notes</span>
          <span class="pct"></span>
          <span class="hair"></span>
          <span class="rail"></span>
        </div>
      </div>
      <p class="cap">the rail runs backwards too</p>
    </div>
  </body>
</html>