Focus — trapped in a dialog
Variant: Trapped in a dialog
The same wrap as the roving menu, decided the other way. Inside a dialog the return travels, because 352ms across three stacked controls is a distance the eye can follow — the pair is here to be read together, and the rule they share is that the wrap gets the hop’s rate and never the hop’s duration. What this one adds is the boundary. The edge flashes on the Tab that wraps and on no other Tab, because a trap that is silent is indistinguishable from a page that simply ran out of controls, and being able to tell the difference is the entire reason to trap focus. The flash is 0.8% of scale over 294ms on an overlay rather than a border on the dialog, since a border that appears displaces everything inside it by its own width on the frame it arrives. Under reduced motion the scale goes and the flash stays: it is the only signal left that the wrap happened at all.
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 — trapped in a dialog</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 — 6.4s. A neighbouring hop is 218ms and the wrap is 352ms, the
same rate over twice the distance. Inside a dialog that rate is
affordable; the same wrap down a twelve-row menu is not, which is why
the menu in this family cuts instead. */
--cycle: 6.4s;
--t: calc(var(--cycle) / var(--speed));
--ease-accent: cubic-bezier(0.32, 0.72, 0, 1);
--_ctl: 26px;
--_gap: 6px;
--_step: calc(var(--_ctl) + 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: 12px;
}
.page {
position: relative;
overflow: hidden;
width: clamp(206px, 74vw, 262px);
height: 198px;
padding: 12px;
display: grid;
align-content: start;
gap: 9px;
border-radius: calc(10px * var(--round));
background: var(--paper);
border: 1px solid color-mix(in srgb, var(--slate) 12%, transparent);
}
.bar {
height: 7px;
border-radius: 999px;
background: color-mix(in srgb, var(--slate) 24%, transparent);
}
.scrim {
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--slate) 11%, transparent);
}
.dialog {
position: absolute;
left: 17px;
right: 17px;
top: 50%;
transform: translateY(-50%);
padding: 11px;
border-radius: calc(10px * var(--round));
background: var(--paper);
box-shadow: 0 10px 26px -14px color-mix(in srgb, var(--slate) 65%, transparent);
}
.title {
font-size: 11.5px;
font-weight: 600;
letter-spacing: -0.01em;
}
.sub {
margin-top: 4px;
font-size: 10.5px;
color: color-mix(in srgb, var(--slate) 48%, transparent);
}
.stack {
position: relative;
margin-top: 9px;
display: grid;
gap: var(--_gap);
}
.ctl {
display: flex;
align-items: center;
justify-content: center;
height: var(--_ctl);
padding: 0 10px;
border-radius: calc(7px * var(--round));
font-size: 11px;
letter-spacing: -0.005em;
}
.input {
justify-content: flex-start;
background: color-mix(in srgb, var(--oat) 26%, var(--paper));
border: 1px solid color-mix(in srgb, var(--slate) 13%, transparent);
color: color-mix(in srgb, var(--slate) 45%, transparent);
}
.ghost {
background: var(--paper);
border: 1px solid color-mix(in srgb, var(--slate) 15%, transparent);
}
.danger {
background: var(--rust);
color: var(--ivory);
font-weight: 550;
}
.ring {
position: absolute;
left: -4px;
right: -4px;
top: -4px;
height: calc(var(--_ctl) + 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: cycle var(--t) infinite;
}
/* The edge is a separate overlay rather than a border on the dialog,
because a border that appears would move the dialog's contents by its
own width on the frame it arrives. */
.edge {
position: absolute;
inset: -1.5px;
border: 1.5px solid color-mix(in srgb, var(--clay) 70%, transparent);
border-radius: calc(11.5px * var(--round));
opacity: 0;
pointer-events: none;
animation: boundary 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 cycle {
0%,
17% {
transform: translateY(0);
animation-timing-function: var(--ease-accent);
}
20.4%,
37% {
transform: translateY(var(--_step));
animation-timing-function: var(--ease-accent);
}
40.4%,
58% {
transform: translateY(calc(var(--_step) * 2));
animation-timing-function: var(--ease-accent);
}
63.5%,
100% {
transform: translateY(0);
}
}
/* Fires on the Tab that wraps, not on every Tab. A trap that is silent is
indistinguishable from a page that ran out of controls, and the whole
reason for trapping focus is that the user should be able to tell. The
scale is 0.8% and lasts 294ms — enough that the edge is doing something
rather than blinking, small enough that the dialog never reads as
having been knocked. */
@keyframes boundary {
0%,
57.4% {
opacity: 0;
transform: scale(1);
animation-timing-function: var(--ease-accent);
}
59.4% {
opacity: 1;
transform: scale(1.008);
animation-timing-function: ease;
}
62%,
100% {
opacity: 0;
transform: scale(1);
}
}
@keyframes tap {
0%,
15.8% {
transform: translateY(0);
background: var(--paper);
animation-timing-function: ease;
}
16.4% {
transform: translateY(1px);
background: color-mix(in srgb, var(--clay) 15%, var(--paper));
animation-timing-function: ease;
}
18.6%,
35.8% {
transform: translateY(0);
background: var(--paper);
animation-timing-function: ease;
}
36.4% {
transform: translateY(1px);
background: color-mix(in srgb, var(--clay) 15%, var(--paper));
animation-timing-function: ease;
}
38.6%,
56.8% {
transform: translateY(0);
background: var(--paper);
animation-timing-function: ease;
}
57.4% {
transform: translateY(1px);
background: color-mix(in srgb, var(--clay) 15%, var(--paper));
animation-timing-function: ease;
}
59.6%,
100% {
transform: translateY(0);
background: var(--paper);
}
}
@media (prefers-reduced-motion: reduce) {
/* The ring cuts between controls. The boundary flash keeps its full
timing and loses only its scale: it is the one thing here that has to
survive, because in this state it is the only signal that the wrap
happened at all. */
.ring {
animation-name: cycle-step;
}
.edge {
animation-name: boundary-flat;
}
.ring,
.edge,
.key {
animation-duration: calc(var(--t) * 1.6);
}
@keyframes cycle-step {
0%,
17% {
transform: translateY(0);
animation-timing-function: steps(1, end);
}
20.4%,
37% {
transform: translateY(var(--_step));
animation-timing-function: steps(1, end);
}
40.4%,
58% {
transform: translateY(calc(var(--_step) * 2));
animation-timing-function: steps(1, end);
}
63.5%,
100% {
transform: translateY(0);
}
}
@keyframes boundary-flat {
0%,
57.4% {
opacity: 0;
animation-timing-function: ease;
}
59.4% {
opacity: 1;
animation-timing-function: ease;
}
62%,
100% {
opacity: 0;
}
}
}
</style>
</head>
<body>
<div
class="scene"
role="img"
aria-label="Tab cycling through a dialog's three controls; on the wrap from the last control back to the first, the dialog's edge flashes to show the boundary"
>
<div class="page">
<span class="bar" style="width: 62%"></span>
<span class="bar"></span>
<span class="bar" style="width: 84%"></span>
<span class="bar" style="width: 40%"></span>
<span class="scrim"></span>
<div class="dialog">
<div class="title">Delete branch?</div>
<div class="sub">This removes 14 commits.</div>
<div class="stack">
<div class="ctl input">release/2.4</div>
<div class="ctl ghost">Cancel</div>
<div class="ctl danger">Delete</div>
<span class="ring"></span>
</div>
<span class="edge"></span>
</div>
</div>
<span class="key"><b>Tab</b> stays inside</span>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.