Back to the library

Scroll — sticky section handoff

Variant: Sticky handoff

A grouped list where each section header pins to the top and is pushed out by the next one arriving underneath, and there is not one hand-timed keyframe in it. Screen position is min(max(0, a), b) against a single scroll scalar — a is where the header would be riding the page, b is the ceiling the next header imposes — which is what `position: sticky` computes, written out. Move a section and every handoff re-times itself, which is the test a simulation built from tuned delays fails the moment the content changes. Being stuck is not a state anyone sets either: it is a below zero, so the shadow is derived from the same number as the position and cannot lag it, ramping over the first 6px of pinning rather than switching on in a frame. Each flick clears a whole section so a handoff lands inside each one, because a shove that stops mid-handoff leaves two headers sharing the rail — the frame everyone screenshots when they think sticky is broken.

scrolllisttransitioncontrol

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 — sticky section handoff</title>
    <style>
      /* The scroll offset in px, registered as a plain number rather than a
         length so the same value can feed a translation and a ratio. Every
         header's behaviour below is arithmetic on this one scalar — there is
         not a single hand-timed keyframe anywhere in the file. */
      @property --_y {
        syntax: '<number>';
        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: 5.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);

        /* Header height, in the same unitless px the offsets are written in. */
        --_hh: 24;
      }

      * {
        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: scroll var(--t) infinite;
      }

      .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));
      }

      .page {
        transform: translateY(calc(var(--_y) * 1px));
      }

      /* The whole of sticky, in two clamps.
           --_a is where the header would be if it simply rode the page.
           --_b is the ceiling the next header imposes as it arrives underneath.
         Screen position is min(max(0, a), b): pinned at the rail once a goes
         negative, then pushed out by exactly as much as b falls below zero. The
         transform is that position minus a, because the page has already
         supplied a. Move a section and the handoffs re-time themselves. */
      .head {
        position: relative;
        z-index: 1;
        height: 24px;
        display: flex;
        align-items: center;
        padding: 0 13px;
        background: var(--paper);
        border-bottom: 1px solid var(--oat);
        font-size: 11px;
        font-weight: 600;
        letter-spacing: 0.08em;
        color: color-mix(in srgb, var(--slate) 50%, transparent);
        --_a: calc(var(--_yi) + var(--_y));
        --_b: calc(var(--_yn) + var(--_y) - var(--_hh));
        transform: translateY(calc((min(max(0, var(--_a)), var(--_b)) - var(--_a)) * 1px));
      }

      /* Being stuck is not a state anyone sets — it is a < 0, so the shadow can
         be derived from the same number as the position and cannot lag it. It
         ramps over the first 6px of pinning rather than switching on, since a
         shadow that appears in one frame reads as a repaint. */
      .head::after {
        content: '';
        position: absolute;
        left: 0;
        right: 0;
        top: 100%;
        height: 8px;
        background: linear-gradient(color-mix(in srgb, var(--slate) 13%, transparent), transparent);
        opacity: clamp(0, calc(var(--_a) / -6), 1);
      }

      .h0 {
        --_yi: 0;
        --_yn: 84;
      }
      .h1 {
        --_yi: 84;
        --_yn: 168;
      }
      .h2 {
        --_yi: 168;
        --_yn: 252;
      }
      /* No successor, so nothing ever pushes the last header off the rail. */
      .h3 {
        --_yi: 252;
        --_yn: 9999;
      }

      .row {
        height: 30px;
        display: flex;
        align-items: center;
        gap: 9px;
        padding: 0 13px;
      }

      .av {
        flex: none;
        width: 16px;
        height: 16px;
        border-radius: 50%;
        background: color-mix(in srgb, var(--clay) 34%, transparent);
      }

      .bars {
        flex: 1;
        display: grid;
        gap: 5px;
      }

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

      .bars i:last-child {
        width: 46%;
      }

      /* Two long flicks down and one throw back to the top. Each flick clears a
         whole section, so one handoff lands inside each — a shove that stops
         mid-handoff leaves two headers sharing the rail, which is the frame
         everyone screenshots when they think sticky is broken. The loop ends
         where it began and needs no dark window to reset in. */
      @keyframes scroll {
        0%,
        12% {
          --_y: 0;
          animation-timing-function: var(--ease-accent);
        }
        26%,
        42% {
          --_y: -92;
          animation-timing-function: var(--ease-accent);
        }
        56%,
        74% {
          --_y: -184;
          animation-timing-function: var(--ease-accent);
        }
        90%,
        100% {
          --_y: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* Parked one section in, so the pinned header and the one still riding
           the page are both on screen. The pinned label carries the only motion
           left — a colour change, which is what reduced motion is allowed to
           keep, and which is the state this piece is about. */
        .rig {
          animation: none;
          --_y: -92;
        }

        .h1 {
          animation: rest-pin calc(var(--t) * 0.55) ease-in-out infinite;
        }

        @keyframes rest-pin {
          0%,
          100% {
            color: color-mix(in srgb, var(--slate) 50%, transparent);
          }
          50% {
            color: var(--clay-d);
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="rig"
      role="img"
      aria-label="A grouped list scrolling: each section header pins to the top of the frame while its rows pass behind it, then is pushed out of the way by the next header arriving underneath"
    >
      <p class="cap">pin · push · hand off</p>
      <div class="frame">
        <div class="page">
          <div class="head h0">TODAY</div>
          <div class="row">
            <span class="av"></span><span class="bars"><i></i><i></i></span>
          </div>
          <div class="row">
            <span class="av"></span><span class="bars"><i></i><i></i></span>
          </div>

          <div class="head h1">YESTERDAY</div>
          <div class="row">
            <span class="av"></span><span class="bars"><i></i><i></i></span>
          </div>
          <div class="row">
            <span class="av"></span><span class="bars"><i></i><i></i></span>
          </div>

          <div class="head h2">LAST WEEK</div>
          <div class="row">
            <span class="av"></span><span class="bars"><i></i><i></i></span>
          </div>
          <div class="row">
            <span class="av"></span><span class="bars"><i></i><i></i></span>
          </div>

          <div class="head h3">EARLIER</div>
          <div class="row">
            <span class="av"></span><span class="bars"><i></i><i></i></span>
          </div>
          <div class="row">
            <span class="av"></span><span class="bars"><i></i><i></i></span>
          </div>
        </div>
      </div>
      <p class="cap">two clamps, no keyframes</p>
    </div>
  </body>
</html>