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.
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.