View change — direction-aware push
Variant: Direction-aware push
The default answer for a hierarchy you can go down into and come back out of. The incoming view crosses the full frame; the outgoing one covers 30% of that and takes a scrim while it goes, so for the whole crossing the previous view is visibly still there, behind — which is what makes "back" a place rather than a command. Forward and back both cost 291ms on purpose: a stack that pops faster than it pushes has stopped being reversible, and reversibility is the entire claim a direction-aware transition makes. The chrome does not travel with either view; the title crossfades with 6px of the same direction, enough to belong to the gesture without being a second animation running beside it, and the two labels are sequenced rather than symmetric so they never sit at half opacity on the same frame.
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>View change — direction-aware push</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 — 5.2s: rest, push forward, sit on the detail, pop back, rest.
Both crossings are 5.6% of the cycle, which is 291ms. */
--cycle: 5.2s;
--t: calc(var(--cycle) / var(--speed));
/* The navigation curve: fast off the mark, long settle. */
--ease-accent: cubic-bezier(0.32, 0.72, 0, 1);
/* The parallax ratio. The outgoing view covers 30% of what the incoming
one covers — enough that it is visibly still there, behind, waiting to
be come back to, rather than a page that left. */
--_behind: -30%;
/* The two sub-pixel-budget offsets in the chrome, held as tokens so the
reduced-motion block can zero both of them in one declaration. */
--_nudge: 6px;
--_lead: 4px;
}
* {
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);
}
.frame {
position: relative;
width: clamp(228px, 74vw, 292px);
aspect-ratio: 3 / 2;
display: grid;
grid-template-rows: auto 1fr;
border-radius: calc(12px * var(--round));
background: var(--paper);
border: 1px solid color-mix(in srgb, var(--slate) 10%, transparent);
overflow: hidden;
}
/* The chrome does not travel. It reports where you are; it is not one of
the places you went. */
.chrome {
position: relative;
height: 34px;
display: flex;
align-items: center;
padding: 0 12px;
gap: 8px;
border-bottom: 1px solid color-mix(in srgb, var(--slate) 8%, transparent);
background: var(--ivory);
}
.back {
display: grid;
place-items: center;
width: 14px;
height: 14px;
flex: none;
color: var(--clay-d);
opacity: 0;
transform: translateX(calc(-1 * var(--_lead)));
animation: back-affordance var(--t) infinite;
}
.back svg {
width: 100%;
height: 100%;
}
.titles {
position: relative;
flex: 1;
height: 14px;
}
/* 6px of the same direction — enough for the label to belong to the
gesture, not enough to be a second animation running beside it. */
.title {
position: absolute;
inset: 0;
display: flex;
align-items: center;
font-size: 12px;
font-weight: 600;
letter-spacing: -0.01em;
}
.title-list {
animation: title-out var(--t) infinite;
}
.title-detail {
opacity: 0;
transform: translateX(var(--_nudge));
animation: title-in var(--t) infinite;
}
.stage {
position: relative;
overflow: hidden;
}
.pane {
position: absolute;
inset: 0;
padding: 12px;
display: grid;
gap: 9px;
align-content: start;
background: var(--paper);
}
.list {
animation: push-behind var(--t) infinite;
}
.detail {
transform: translateX(100%);
border-left: 1px solid color-mix(in srgb, var(--slate) 9%, transparent);
animation: push-in var(--t) infinite;
}
.row {
height: 16px;
border-radius: calc(5px * var(--round));
background: color-mix(in srgb, var(--slate) 8%, transparent);
}
.row-live {
background: color-mix(in srgb, var(--clay) 26%, transparent);
}
.detail .lead {
height: 11px;
width: 62%;
border-radius: calc(4px * var(--round));
background: var(--clay);
}
.detail .line {
height: 8px;
border-radius: 4px;
background: var(--oat);
}
.detail .chip {
height: 20px;
width: 46%;
margin-top: 2px;
border-radius: calc(6px * var(--round));
background: color-mix(in srgb, var(--olive) 24%, transparent);
}
/* Dim the layer, not the content: a scrim over the outgoing view keeps its
text at full contrast underneath, so nothing has to be re-rendered at a
weight it never has at rest. */
.dim {
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--slate) 14%, transparent);
opacity: 0;
animation: dim var(--t) infinite;
}
/* Forward and back cost exactly the same. A stack that pops faster than it
pushes stops being reversible, and reversibility is the only thing a
direction-aware transition is actually claiming. */
@keyframes push-in {
0%,
8% {
transform: translateX(100%);
animation-timing-function: var(--ease-accent);
}
13.6%,
54% {
transform: translateX(0);
animation-timing-function: var(--ease-accent);
}
59.6%,
100% {
transform: translateX(100%);
}
}
@keyframes push-behind {
0%,
8% {
transform: translateX(0);
animation-timing-function: var(--ease-accent);
}
13.6%,
54% {
transform: translateX(var(--_behind));
animation-timing-function: var(--ease-accent);
}
59.6%,
100% {
transform: translateX(0);
}
}
@keyframes dim {
0%,
8% {
opacity: 0;
animation-timing-function: ease;
}
13.6%,
54% {
opacity: 1;
animation-timing-function: ease;
}
59.6%,
100% {
opacity: 0;
}
}
@keyframes back-affordance {
0%,
9% {
opacity: 0;
transform: translateX(calc(-1 * var(--_lead)));
animation-timing-function: var(--ease-accent);
}
13.6%,
53% {
opacity: 1;
transform: translateX(0);
animation-timing-function: var(--ease-accent);
}
57%,
100% {
opacity: 0;
transform: translateX(calc(-1 * var(--_lead)));
}
}
/* The two labels are sequenced rather than symmetric: the outgoing one is
gone in 187ms and the incoming one starts 52ms before it finishes.
Fade them on identical windows and both sit near half opacity on the
same frame, which on two words in the same box reads as one illegible
word rather than as a change of place. */
@keyframes title-out {
0%,
8% {
opacity: 1;
transform: translateX(0);
animation-timing-function: ease;
}
11.6%,
56.6% {
opacity: 0;
transform: translateX(calc(-1 * var(--_nudge)));
animation-timing-function: ease;
}
59.6%,
100% {
opacity: 1;
transform: translateX(0);
}
}
@keyframes title-in {
0%,
10.6% {
opacity: 0;
transform: translateX(var(--_nudge));
animation-timing-function: ease;
}
13.6%,
54% {
opacity: 1;
transform: translateX(0);
animation-timing-function: ease;
}
57.6%,
100% {
opacity: 0;
transform: translateX(var(--_nudge));
}
}
@media (prefers-reduced-motion: reduce) {
/* The travel goes, and with it the direction — there is no gentler way
to say "rightwards" than moving rightwards. What is left has to carry
the state change on its own: the detail crossfades over a dimmed
index, and the back control still appears with it, because the thing
worth preserving is that the previous view is still under there. */
:root {
--_nudge: 0px;
--_lead: 0px;
}
.list {
animation: none;
transform: none;
}
.detail {
transform: translateX(0);
animation: detail-fade calc(var(--t) * 1.5) infinite;
}
.dim,
.back,
.title-list,
.title-detail {
animation-duration: calc(var(--t) * 1.5);
}
@keyframes detail-fade {
0%,
8% {
opacity: 0;
animation-timing-function: ease;
}
13.6%,
54% {
opacity: 1;
animation-timing-function: ease;
}
59.6%,
100% {
opacity: 0;
}
}
}
</style>
</head>
<body>
<div
class="frame"
role="img"
aria-label="A list view pushed aside as a detail view slides in from the right, then reversed exactly when the back control is used"
>
<div class="chrome">
<span class="back">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M15 5 8 12l7 7"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<span class="titles">
<span class="title title-list">Inbox</span>
<span class="title title-detail">Message</span>
</span>
</div>
<div class="stage">
<div class="pane list">
<span class="row row-live"></span>
<span class="row" style="width: 86%"></span>
<span class="row" style="width: 94%"></span>
<span class="row" style="width: 72%"></span>
<span class="dim"></span>
</div>
<div class="pane detail">
<span class="lead"></span>
<span class="line" style="width: 92%"></span>
<span class="line" style="width: 80%"></span>
<span class="line" style="width: 88%"></span>
<span class="chip"></span>
</div>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.