Back to the library

Text cycle — marquee

Variant: Marquee

A metrics ticker, and the seam is the whole engineering problem: the strip holds two identical runs and travels exactly -50% of the pair, so there are no measured widths, no per-item arithmetic, and it stays seamless when the type reflows at another stage size. Each run carries a trailing gap of its own, which is what makes the joint between the two spaced like every other. Linear, always — a marquee that eases is visibly a slideshow, and the constant rate is the only thing making it readable. The aperture is masked at both edges rather than cut, because at a hard line every item pops in and out and the eye tracks the pop instead of the reading. Sized to travel about 54px a second on a desktop stage, which is a reading pace rather than a scroll.

textloopsweepdata

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>Text cycle — marquee</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 — one run per cycle. 16s over a run that is roughly 870px on a
           desktop stage puts the strip at about 54px a second; the type clamps
           down on a phone and the rate falls with it, which is the behaviour you
           want, because there is less strip to read. */
        --cycle: 16s;
        --t: calc(var(--cycle) / var(--speed));

        --_pad: clamp(16px, 3.8vw, 26px);
        --_gap: clamp(14px, 3vw, 26px);
        --_fade: clamp(22px, 7vw, 44px);
        --_read: color-mix(in srgb, var(--slate) 55%, transparent);
      }

      * {
        box-sizing: border-box;
        margin: 0;
        padding: 0;
      }

      body {
        min-height: 100vh;
        display: grid;
        place-items: center;
        padding: clamp(12px, 3vw, 28px);
        background: var(--ivory);
        font-family:
          system-ui,
          -apple-system,
          'Segoe UI',
          Roboto,
          sans-serif;
        color: var(--slate);
      }

      .card {
        width: min(360px, 92vw);
        padding: clamp(15px, 3.6vw, 24px) var(--_pad);
        border: 1px solid var(--oat);
        border-radius: calc(14px * var(--round));
        background: var(--paper);
      }

      .eyebrow {
        font-size: clamp(11px, 2.4vw, 12.5px);
        font-weight: 600;
        letter-spacing: 0.09em;
        text-transform: uppercase;
        color: var(--_read);
      }

      /* The strip bleeds to the card's edges rather than stopping inside its
         padding: a ticker that begins and ends in clear air is a list that
         happens to be moving. The mask is what makes the edges an aperture —
         without it every item pops in and out at a hard line, and the eye tracks
         the pop instead of the reading. */
      .ticker {
        margin: clamp(10px, 2vw, 15px) calc(-1 * var(--_pad)) 0;
        overflow: hidden;
        mask-image: linear-gradient(
          90deg,
          transparent 0,
          #000 var(--_fade),
          #000 calc(100% - var(--_fade)),
          transparent 100%
        );
        -webkit-mask-image: linear-gradient(
          90deg,
          transparent 0,
          #000 var(--_fade),
          #000 calc(100% - var(--_fade)),
          transparent 100%
        );
      }

      .track {
        display: flex;
        width: max-content;
        animation: marquee var(--t) linear infinite;
      }

      /* Two identical runs, and the travel is exactly -50% of the pair. That is
         the whole seam strategy: no measured widths, no per-item maths, and it
         stays correct when the type reflows at another stage size. The trailing
         padding makes each run's own width include the gap that follows its last
         separator, so the joint between the runs is spaced like every other. */
      .run {
        display: flex;
        align-items: center;
        gap: var(--_gap);
        padding-right: var(--_gap);
        flex: none;
      }

      @keyframes marquee {
        from {
          transform: translateX(0);
        }
        to {
          transform: translateX(-50%);
        }
      }

      .item {
        display: inline-flex;
        align-items: baseline;
        gap: 0.42em;
        white-space: nowrap;
        font-size: clamp(12.5px, 3vw, 16px);
        flex: none;
      }

      .k {
        color: var(--_read);
      }

      .v {
        font-weight: 600;
        font-variant-numeric: tabular-nums;
        letter-spacing: -0.01em;
      }

      .sep {
        width: 3px;
        height: 3px;
        border-radius: 50%;
        background: var(--clay);
        flex: none;
      }

      @media (prefers-reduced-motion: reduce) {
        /* Gentler, not absent — and a ticker is the one component whose travel
           cannot simply be dropped, because the travel is the component. So the
           pass slows to 2.6x, which puts it near 20px a second, and the fades
           are widened to more than a third of the aperture so far less of the
           strip is ever at full contrast at once. */
        .track {
          animation-duration: calc(var(--t) * 2.6);
        }

        .ticker {
          --_fade: clamp(48px, 18vw, 120px);
        }
      }
    </style>
  </head>
  <body>
    <div class="card">
      <p class="eyebrow">Fleet</p>
      <div
        class="ticker"
        role="img"
        aria-label="Live fleet metrics scrolling: api 142 milliseconds, queue 0, errors 0.02 percent, p95 310 milliseconds, builds 4, uptime 99.98 percent."
      >
        <div class="track">
          <div class="run" aria-hidden="true">
            <span class="item"><span class="k">api</span><span class="v">142ms</span></span>
            <span class="sep"></span>
            <span class="item"><span class="k">queue</span><span class="v">0</span></span>
            <span class="sep"></span>
            <span class="item"><span class="k">errors</span><span class="v">0.02%</span></span>
            <span class="sep"></span>
            <span class="item"><span class="k">p95</span><span class="v">310ms</span></span>
            <span class="sep"></span>
            <span class="item"><span class="k">builds</span><span class="v">4</span></span>
            <span class="sep"></span>
            <span class="item"><span class="k">uptime</span><span class="v">99.98%</span></span>
            <span class="sep"></span>
          </div>
          <div class="run" aria-hidden="true">
            <span class="item"><span class="k">api</span><span class="v">142ms</span></span>
            <span class="sep"></span>
            <span class="item"><span class="k">queue</span><span class="v">0</span></span>
            <span class="sep"></span>
            <span class="item"><span class="k">errors</span><span class="v">0.02%</span></span>
            <span class="sep"></span>
            <span class="item"><span class="k">p95</span><span class="v">310ms</span></span>
            <span class="sep"></span>
            <span class="item"><span class="k">builds</span><span class="v">4</span></span>
            <span class="sep"></span>
            <span class="item"><span class="k">uptime</span><span class="v">99.98%</span></span>
            <span class="sep"></span>
          </div>
        </div>
      </div>
    </div>
  </body>
</html>

The variant family

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