Back to the library

Focus — the ring travels

Variant: Ring travels

Three fields and one ring, which is the whole argument: three rings fading out and in animate the same pixels and say something else entirely — that focus was destroyed here and built again there. Focus is a single thing all the way down a form, and a snippet only reads that way if there is literally one element doing it. The travel runs at a fixed rate rather than a fixed duration, so a hop to the next field costs 240ms and the wrap back to the top costs 402ms over twice the distance; give the wrap the hop’s duration and it stops being travel and becomes a jump that happened to be animated. Ease-out and not ease-in-out, because the ring is answering a keypress rather than crossing the screen on its own initiative. The keycap bottoms out 36ms before the ring leaves — reverse those two and the demo reads as an interface moving focus by itself, which is a different and much worse product.

focuskeyboardcontroltransitionloop

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>Focus — the ring travels</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 — 6s. One row costs 4% of it (240ms) and two rows cost 6.7%
           (402ms): the ring is not on a fixed duration, it is on a fixed rate,
           which is the only way a wrap back to the top reads as travel rather
           than as a jump that happened to be animated. */
        --cycle: 6s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-accent: cubic-bezier(0.32, 0.72, 0, 1);

        /* The ring is out of flow, so its step has to equal the row pitch to the
           pixel — there is no layout to correct a mismatch. */
        --_row: 32px;
        --_gap: 8px;
        --_step: calc(var(--_row) + var(--_gap));
      }

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

      .scene {
        display: grid;
        justify-items: center;
        gap: 13px;
      }

      .form {
        position: relative;
        width: clamp(196px, 70vw, 244px);
        display: grid;
        gap: var(--_gap);
      }

      .field {
        display: flex;
        align-items: center;
        gap: 8px;
        height: var(--_row);
        padding: 0 10px;
        border-radius: calc(7px * var(--round));
        background: var(--paper);
        border: 1px solid color-mix(in srgb, var(--slate) 11%, transparent);
        font-size: 11px;
        letter-spacing: -0.005em;
        color: color-mix(in srgb, var(--slate) 52%, transparent);
      }

      .f1 {
        animation: lit-1 var(--t) infinite;
      }

      .f2 {
        animation: lit-2 var(--t) infinite;
      }

      .f3 {
        animation: lit-3 var(--t) infinite;
      }

      .hint {
        margin-left: auto;
        font-size: 10.5px;
        color: color-mix(in srgb, var(--slate) 32%, transparent);
      }

      /* One element for all three stops. Three rings that fade in and out would
         animate the same pixels and say something different: that focus was
         destroyed here and recreated there. Focus is a single thing the whole
         way down the form, and the snippet only reads that way if there is
         literally one ring on the page. */
      .ring {
        position: absolute;
        left: -4px;
        right: -4px;
        top: -4px;
        height: calc(var(--_row) + 8px);
        border: 2px solid var(--clay);
        border-radius: calc(10px * var(--round));
        box-shadow: 0 0 0 3px color-mix(in srgb, var(--clay) 15%, transparent);
        pointer-events: none;
        animation: travel var(--t) infinite;
      }

      .key {
        display: flex;
        align-items: center;
        gap: 6px;
        padding: 4px 9px;
        border-radius: calc(6px * var(--round));
        background: var(--paper);
        border: 1px solid color-mix(in srgb, var(--slate) 14%, transparent);
        box-shadow: 0 1px 0 color-mix(in srgb, var(--slate) 10%, transparent);
        font-size: 11px;
        color: color-mix(in srgb, var(--slate) 55%, transparent);
        animation: tap var(--t) infinite;
      }

      .key b {
        font-weight: 600;
        color: color-mix(in srgb, var(--slate) 80%, transparent);
      }

      @keyframes travel {
        0%,
        18% {
          transform: translateY(0);
          animation-timing-function: var(--ease-accent);
        }
        22%,
        40% {
          transform: translateY(var(--_step));
          animation-timing-function: var(--ease-accent);
        }
        44%,
        62% {
          transform: translateY(calc(var(--_step) * 2));
          animation-timing-function: var(--ease-accent);
        }
        68.7%,
        100% {
          transform: translateY(0);
        }
      }

      /* The tint hands over across the travel window rather than at either end,
         so no field is ever the lit one while the ring is somewhere else. */
      @keyframes lit-1 {
        0%,
        18% {
          background: color-mix(in srgb, var(--clay) 7%, var(--paper));
          color: var(--slate);
          animation-timing-function: ease;
        }
        22%,
        64% {
          background: var(--paper);
          color: color-mix(in srgb, var(--slate) 52%, transparent);
          animation-timing-function: ease;
        }
        68.7%,
        100% {
          background: color-mix(in srgb, var(--clay) 7%, var(--paper));
          color: var(--slate);
        }
      }

      @keyframes lit-2 {
        0%,
        18% {
          background: var(--paper);
          color: color-mix(in srgb, var(--slate) 52%, transparent);
          animation-timing-function: ease;
        }
        22%,
        40% {
          background: color-mix(in srgb, var(--clay) 7%, var(--paper));
          color: var(--slate);
          animation-timing-function: ease;
        }
        44%,
        100% {
          background: var(--paper);
          color: color-mix(in srgb, var(--slate) 52%, transparent);
        }
      }

      @keyframes lit-3 {
        0%,
        40% {
          background: var(--paper);
          color: color-mix(in srgb, var(--slate) 52%, transparent);
          animation-timing-function: ease;
        }
        44%,
        62% {
          background: color-mix(in srgb, var(--clay) 7%, var(--paper));
          color: var(--slate);
          animation-timing-function: ease;
        }
        66%,
        100% {
          background: var(--paper);
          color: color-mix(in srgb, var(--slate) 52%, transparent);
        }
      }

      /* The key bottoms out 36ms before the ring leaves. Input precedes
         response — reverse the two and the demo reads as the interface moving
         focus on its own, which is a different and much worse product. */
      @keyframes tap {
        0%,
        16.8% {
          transform: translateY(0);
          background: var(--paper);
          animation-timing-function: ease;
        }
        17.4% {
          transform: translateY(1px);
          background: color-mix(in srgb, var(--clay) 15%, var(--paper));
          animation-timing-function: ease;
        }
        20%,
        38.8% {
          transform: translateY(0);
          background: var(--paper);
          animation-timing-function: ease;
        }
        39.4% {
          transform: translateY(1px);
          background: color-mix(in srgb, var(--clay) 15%, var(--paper));
          animation-timing-function: ease;
        }
        42%,
        60.8% {
          transform: translateY(0);
          background: var(--paper);
          animation-timing-function: ease;
        }
        61.4% {
          transform: translateY(1px);
          background: color-mix(in srgb, var(--clay) 15%, var(--paper));
          animation-timing-function: ease;
        }
        64%,
        100% {
          transform: translateY(0);
          background: var(--paper);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The ring cannot stop moving — it would sit on one field forever and
           the snippet would stop being about focus at all. What it gives up is
           the interpolation: steps(1) puts it on the next field on the frame
           the state changes, which is what a browser with no transition does.
           The tints keep easing, because colour is the part that carries the
           meaning rather than the part that causes the discomfort. */
        .ring {
          animation-name: travel-step;
        }

        .ring,
        .f1,
        .f2,
        .f3,
        .key {
          animation-duration: calc(var(--t) * 1.6);
        }

        @keyframes travel-step {
          0%,
          18% {
            transform: translateY(0);
            animation-timing-function: steps(1, end);
          }
          22%,
          40% {
            transform: translateY(var(--_step));
            animation-timing-function: steps(1, end);
          }
          44%,
          62% {
            transform: translateY(calc(var(--_step) * 2));
            animation-timing-function: steps(1, end);
          }
          68.7%,
          100% {
            transform: translateY(0);
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="scene"
      role="img"
      aria-label="A single focus ring travelling down a three-field form as Tab is pressed, then returning to the first field in proportionally longer time"
    >
      <div class="form">
        <div class="field f1">
          <span>Full name</span>
          <span class="hint">required</span>
        </div>
        <div class="field f2">
          <span>Email</span>
          <span class="hint">required</span>
        </div>
        <div class="field f3">
          <span>Company</span>
          <span class="hint">optional</span>
        </div>
        <span class="ring"></span>
      </div>
      <span class="key"><b>Tab</b> moves focus</span>
    </div>
  </body>
</html>

The variant family

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