View change — shared element
Variant: Shared element
Continuity done properly: the tile that was tapped is the card that arrives, so nothing has to be recognised twice. The whole travel is one translate and one scale off a top-left origin, and both numbers fall out of the gutter alone — with a gutter fraction g, one cell plus one gutter is (1+g)/(1−g) of a cell and the whole stage is 2/(1−g), and those differ by exactly one. The corners scale with everything else, so 6px lands as 12.5px; that is the one artifact a uniform-scale shared element cannot dodge, and it is still the better trade than animating width and height into a layout pass on every frame. The caption is not shared and so does not travel: it arrives 56ms after the card stops and leaves 140ms before it starts back, because content riding a shrinking surface reads as falling into it.
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 — shared element</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.6s: grid, the tile becomes the view, the view holds, it goes
back to being a tile. The growth is 6% of the cycle (336ms) and the
return 5.5% (308ms). */
--cycle: 5.6s;
--t: calc(var(--cycle) / var(--speed));
--ease-accent: cubic-bezier(0.32, 0.72, 0, 1);
/* The gutter, as a fraction of the stage in both axes — which is what
makes the whole transition one number. A two-column grid with a
gutter g means one cell plus one gutter is (1+g)/(1-g) of a cell, and
the whole stage is 2/(1-g) of a cell. Those are the shift and the
scale, and they differ by exactly 1. */
--_gut: 0.04;
--_grow: calc(2 / (1 - var(--_gut)));
}
* {
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;
border-radius: calc(12px * var(--round));
background: var(--paper);
border: 1px solid color-mix(in srgb, var(--slate) 10%, transparent);
overflow: hidden;
}
/* A definite box in both axes, so the percentage gutter resolves against
a known width and a known height and the cells stay square-ish at every
stage size the card is previewed at. */
.stage {
position: absolute;
inset: 12px;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: calc(var(--_gut) * 100%);
}
.tile {
border-radius: calc(6px * var(--round));
background: color-mix(in srgb, var(--slate) 7%, transparent);
}
/* The siblings are gone before the card is over them. Overlap the two and
the card reads as a new surface landing on top of the grid, rather than
as the grid becoming the card. */
.tile:not(.hero) {
animation: siblings var(--t) infinite;
}
.tile:nth-child(3) {
background: color-mix(in srgb, var(--olive) 20%, transparent);
}
/* Origin at the tile's own top-left, so the two functions read straight
off the geometry: shift one cell-plus-gutter to the left, then scale the
cell up to the stage. No measuring, no per-size maths, no translate in
y — the tile is already on the row it is going to fill. */
.hero {
position: relative;
z-index: 1;
background: var(--clay);
transform-origin: 0 0;
animation: expand var(--t) infinite;
}
/* Scaling a rounded box scales its corners too — 6px arrives as 12.5px.
That is the one artifact a uniform-scale shared element cannot dodge,
and it is still the right trade: 12.5px is a plausible radius for a
card this size, whereas animating width and height to avoid it costs a
layout pass on every frame of the travel. */
.hero .mark {
position: absolute;
top: 22%;
left: 12%;
width: 26%;
aspect-ratio: 1;
border-radius: 50%;
background: color-mix(in srgb, var(--paper) 42%, transparent);
}
/* Only the element is shared. The destination's own content is not in the
grid anywhere, so it cannot travel from it — it arrives afterwards, as
its own step, once the card has stopped. */
.caption {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
z-index: 2;
padding: 9px 10px;
display: grid;
gap: 6px;
border-radius: calc(7px * var(--round));
background: color-mix(in srgb, var(--paper) 93%, transparent);
opacity: 0;
transform: translateY(5px);
animation: caption var(--t) infinite;
}
.caption .title {
font-size: 11.5px;
font-weight: 600;
letter-spacing: -0.01em;
}
.caption .line {
height: 6px;
border-radius: 3px;
background: var(--oat);
}
@keyframes expand {
0%,
10.5% {
transform: translateX(0) scale(1);
animation-timing-function: var(--ease-accent);
}
16.5%,
60.5% {
transform: translateX(calc((1 - var(--_grow)) * 100%)) scale(var(--_grow));
animation-timing-function: var(--ease-accent);
}
66%,
100% {
transform: translateX(0) scale(1);
}
}
@keyframes siblings {
0%,
9% {
opacity: 1;
animation-timing-function: ease;
}
12.5%,
64.5% {
opacity: 0;
animation-timing-function: ease;
}
68.5%,
100% {
opacity: 1;
}
}
/* In at 17.5%, a beat after the card settles at 16.5%; out at 58%, a beat
before it starts back. Content that leaves with the card it is sitting
on rides a shrinking surface and reads as falling into it. */
@keyframes caption {
0%,
17.5% {
opacity: 0;
transform: translateY(5px);
animation-timing-function: var(--ease-accent);
}
22%,
58% {
opacity: 1;
transform: translateY(0);
animation-timing-function: ease;
}
60.5%,
100% {
opacity: 0;
transform: translateY(5px);
}
}
@media (prefers-reduced-motion: reduce) {
/* A shared element is travel by definition — with the travel gone there
is no transition left to soften, only a destination. So the card is
parked at it and the one thing that was never part of the travel keeps
running: the view's own content arriving after the surface it sits on. */
.hero {
animation: none;
transform: translateX(calc((1 - var(--_grow)) * 100%)) scale(var(--_grow));
}
.tile:not(.hero) {
animation: none;
opacity: 0;
}
.caption {
transform: none;
animation: caption-fade calc(var(--t) * 1.5) ease infinite;
}
@keyframes caption-fade {
0%,
17.5% {
opacity: 0;
}
22%,
58% {
opacity: 1;
}
62%,
100% {
opacity: 0;
}
}
}
</style>
</head>
<body>
<div
class="frame"
role="img"
aria-label="A tile in a grid growing into a full card view, its caption arriving once it settles, then shrinking back to its place in the grid"
>
<div class="stage">
<div class="tile"></div>
<div class="tile hero"><span class="mark"></span></div>
<div class="tile"></div>
<div class="tile"></div>
<div class="caption">
<span class="title">Harbour, 06:40</span>
<span class="line" style="width: 88%"></span>
<span class="line" style="width: 62%"></span>
</div>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.