Back to the library

Scroll — parallax depth

Variant: Parallax depth

Three layers, one keyframes block, three amplitudes — and that is the whole build, because depth is a ratio and nothing else. The common failure is to give each layer its own curve or its own duration, which produces three animations that happen to be stacked rather than one scene at three distances; ride them all on the same clock and the same easing and the parallax cannot come apart, however far the hand drags. The far ridge shares the sky’s amplitude rather than taking a fourth rate, since three named rates is a legend a reader can hold and four is a texture. This is also the only member of the family that needs no dark window to reset in: the pass goes down, holds, and comes back, so the last frame is the first frame and nothing in it latches. Keep the front layer at 1× — a parallax that moves the text you are reading is a parallax that has forgotten what the page is for.

scrollparallaxambientloop

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 — parallax depth</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)
        );

        /* Clock — every duration in the document runs off --t. */
        --cycle: 5.2s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-in-out: cubic-bezier(0.77, 0, 0.175, 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);
      }

      .rig {
        width: min(190px, 86vw);
        display: grid;
        gap: 10px;
        justify-items: center;
      }

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

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

      /* Every layer runs the identical keyframes at the identical rate and
         differs only in --_amp. Depth is a ratio of amplitudes; give the layers
         separate curves and you no longer have one scene at three distances,
         you have three animations that happen to be stacked. */
      .layer {
        position: absolute;
        left: 0;
        right: 0;
        animation: drift var(--t) infinite;
      }

      .sky {
        top: 0;
        height: 110px;
        --_amp: 20px;
        background: linear-gradient(
          180deg,
          color-mix(in srgb, var(--clay) 26%, transparent),
          transparent 78%
        );
      }

      .sun {
        position: absolute;
        left: 23%;
        top: 26px;
        width: 28px;
        height: 28px;
        border-radius: 50%;
        background: color-mix(in srgb, var(--clay) 52%, transparent);
      }

      /* The far ridge rides the sky's amplitude rather than taking a fourth
         rate of its own: three named rates is a legend a reader can hold. */
      .far {
        position: absolute;
        left: 0;
        right: 0;
        top: 66px;
        height: 44px;
      }

      .ridge {
        top: 58px;
        height: 112px;
        --_amp: 43px;
      }

      /* The travel stops well short of burying the scene. A parallax whose front
         layer covers the back ones at the end of the pass spends its dwell frame
         proving nothing — the ratio is only legible while more than one distance
         is on screen. */
      .deck {
        top: 118px;
        height: 152px;
        --_amp: 72px;
        padding: 13px 13px 0;
        background: var(--paper);
        border-top: 1.5px solid var(--oat);
        border-radius: calc(13px * var(--round)) calc(13px * var(--round)) 0 0;
        box-shadow: 0 -7px 18px color-mix(in srgb, var(--slate) 9%, transparent);
      }

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

      /* Fills live here rather than on a fill="" attribute: custom properties
         are not substituted into SVG presentation attributes, so a token written
         there would silently fall back to black. */
      .far path {
        fill: color-mix(in srgb, var(--olive) 26%, transparent);
      }

      .ridge path {
        fill: color-mix(in srgb, var(--olive) 58%, transparent);
      }

      .title {
        font-size: 12px;
        letter-spacing: 0.01em;
        font-weight: 600;
        margin-bottom: 10px;
      }

      .lines {
        display: grid;
        gap: 7px;
      }

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

      .lines i:nth-child(2) {
        width: 84%;
      }

      .lines i:nth-child(3) {
        width: 61%;
      }

      /* Down, hold, back up, hold — and the last frame is the first frame, so
         this is the one piece in the family that needs no dark window to reset
         in. A parallax is reversible by construction; nothing in it latches. */
      @keyframes drift {
        0%,
        6% {
          transform: translateY(0);
          animation-timing-function: var(--ease-in-out);
        }
        46%,
        54% {
          transform: translateY(calc(var(--_amp) * -1));
          animation-timing-function: var(--ease-in-out);
        }
        94%,
        100% {
          transform: translateY(0);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* Parked at the bottom of the travel, where the scene is deepest. The
           sun is the only thing left running: it is the one mark far enough
           back that a slow brightness change cannot be mistaken for travel. */
        .layer {
          animation: none;
          transform: translateY(calc(var(--_amp) * -1));
        }

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

        @keyframes rest-sun {
          0%,
          100% {
            opacity: 0.45;
          }
          50% {
            opacity: 1;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="rig"
      role="img"
      aria-label="A scene scrolling: a sky and sun drift a little, a ridge drifts more, and a sheet of content travels the full distance over both, then everything returns"
    >
      <p class="cap">0.28× · 0.6× · 1×</p>
      <div class="frame">
        <div class="layer sky">
          <span class="sun"></span>
          <div class="far">
            <svg viewBox="0 0 190 42" preserveAspectRatio="none" aria-hidden="true">
              <path d="M0 27 L22 15 L44 25 L72 9 L104 24 L134 13 L162 24 L190 16 L190 42 L0 42 Z" />
            </svg>
          </div>
        </div>

        <div class="layer ridge">
          <svg viewBox="0 0 190 112" preserveAspectRatio="none" aria-hidden="true">
            <path d="M0 44 L28 27 L54 42 L84 16 L116 40 L146 24 L190 47 L190 112 L0 112 Z" />
          </svg>
        </div>

        <div class="layer deck">
          <p class="title">Field notes</p>
          <div class="lines"><i></i><i></i><i></i></div>
        </div>
      </div>
      <p class="cap">one curve, three amplitudes</p>
    </div>
  </body>
</html>