Foundations · 12
Page Transitions
A static site normally navigates the way paper does: the sheet is thrown away and another one is put down. This one does not. Navigation is drawn — one edge at the brand angle crosses the screen, the arriving page appears behind it, and the light layer rides the edge. Contour before fill, spent on a moment instead of on a box.
You are looking at it
There is no isolated demo on this page, because the documentation is the demo: every link in the sidebar is a cross-document navigation between two pages that both opt in. Click one of these and watch the left edge of the content.
The last one is worth taking twice. Going from the documentation to a pattern page, the nav bar and the footer arrive on their own rather than with the page, because they are named elements and the documentation has neither of them to hand over. Going from one pattern page to another — which is what a reader of the real site does — they do not move at all.
prefers-reduced-motion: no-preference, so it never applies.
The edge, posed
Three positions of the same edge, built the same way the shipping transition is and
held still. The pale half is the page arriving, the grey half is the page leaving, and
the line between them is not a border on either of them — it is
--gradient-foil, showing through a gap where the outgoing page has already
been cut away and the incoming one has not yet reached. One value is enlarged: the gap
is 6 px here and --stroke-2 in the real thing, because at a sixth of
viewport width two pixels is a hairline you cannot read a colour off. The angle, the
run and the clip arithmetic are the shipping values.
The hue is the whole point of drawing it this way rather than picking a colour. The foil
is one gradient across the entire viewport and only two pixels of it are ever visible at
once, so the line changes as it travels — lime for the first instant, Glas and Sky
through the middle, Violett as it leaves, which is exactly the order of the three
frames above. That is the north star of the brand — iridescent, light-reactive,
shifting as you move past it — obtained by moving past it, which is the only way a real
foil ever gives it up. Nothing here is a second gradient; it is
--gradient-foil sampled by an aperture.
Why 63.43°
Both isometric angles were available and only one of them sweeps. An edge at
--angle-b (26.57°) is nearly horizontal, and a nearly horizontal edge
travelling sideways reads as a page sliding rather than as light crossing it. So the
edge is --angle-a, the steep isometric — which is the same rhombus edge
read off the other axis, not a fifth angle. Its horizontal run over one viewport height
is tan(90° − 63.43°) = 0.5, and that half-height is the token
--angle-a-run. Change --angle-a and the token has to move with
it; it is the one place in the system where the isometry is resolved against the
viewport instead of against an object, because a page-wide edge has no bounding box of
its own to be built on.
The duration is --duration-slow, 480 ms, and it is not a new speed. That
token's stated job is a reveal, or light crossing a surface, and this is both at
once: it is the same gesture as the specular sliding across a lit button and
--sheen-panel crossing an accordion row, at the scale of a whole page. The
curve is --ease-out — the edge enters fast and settles, like every other
object in the system that flies in and stays.
What holds still
A view-transition-name lifts an element out of the page snapshot and gives
it a transition of its own. For chrome that is identical on both pages, that means it
holds perfectly still while everything behind it is raked across — so the nav bar does
not blink, the footer does not blink, and the reader is left with the impression of one
continuous space rather than a sequence of sheets. This is the brand's opening claim
(a data space, not a sheet of paper) made literal at the one moment a static
site normally contradicts it.
| Selector | Name | Behaviour | Declared in |
|---|---|---|---|
body > .cf-nav .cf-logo |
cf-nav-logo |
Identical on every page, so it holds still. | base.css — ships |
body > .cf-nav .cf-nav__list |
cf-nav-links |
Same. Only the current-page plate differs, which the default cross-fade handles. | base.css — ships |
body > .cf-nav .cf-nav__toggle |
cf-nav-toggle |
The chrome that survives on the phone layout. Not rendered above 780 px, so not captured there. | base.css — ships |
body > .cf-footer |
cf-footer |
Same. Also below the fold on most pages, so mostly invisible work. | base.css — ships |
.docs-sidebar |
docs-sidebar |
Old and new differ in one thing — which entry carries the current-page plate — so the UA's default cross-fade is exactly right and is left alone. Dropped below 900 px, where the sidebar stacks above the content and stops being furniture. | docs.css — does not ship |
root |
root |
Everything else: the page wash and the content. This is what the edge crosses. | UA |
body > combinator is load-bearing, not tidiness. A
view-transition-name has to be unique in the document, and if two elements
claim the same one the browser abandons the entire transition and logs an error.
components/navigation.html and components/footer.html each
show a specimen of the chrome, inside a .docs-demo — so the combinator is
the line between the page's own furniture and a drawing of it. The specimen keeps no
name and travels with the page like any other content. Any new page that needs a second
nav or a second footer in the same document has to solve the uniqueness problem, not
inherit it.
One consequence is free and worth knowing: the page wash is
background-attachment: fixed, so it is painted against the viewport rather
than against the document. Two pages of different lengths, at different scroll
positions, therefore have pixel-identical wash. The edge crosses it and you
cannot see the edge in it. The wipe is visible only where the two pages actually differ,
which is the content — so the room holds and the contents of the room change, without
that having to be arranged.
Adding it to a page
Nothing. It is in base.css, both pages of any navigation already have it,
and there is no markup, no attribute and no script. What follows is the whole
implementation, for reading rather than for pasting.
/* Opt in. Both documents must; a page that does not is navigated to plainly. */ @media (prefers-reduced-motion: no-preference) { @view-transition { navigation: auto; } } /* Furniture. Named = lifted out of the page snapshot = holds still. The bar's two plates, not the bar — see "The bar cannot carry the name". */ body > .cf-nav .cf-logo { view-transition-name: cf-nav-logo; } body > .cf-nav .cf-nav__list { view-transition-name: cf-nav-links; } body > .cf-nav .cf-nav__toggle { view-transition-name: cf-nav-toggle; } body > .cf-footer { view-transition-name: cf-footer; } @media (prefers-reduced-motion: no-preference) { /* The UA default is plus-lighter, correct for a cross-fade and wrong for a wipe: under a wipe both snapshots are opaque wherever they overlap, and plus-lighter adds two opaque pages to white. */ ::view-transition-old(root), ::view-transition-new(root) { mix-blend-mode: normal; animation: none; } /* The light layer, seen through the gap. The group paints behind both snapshots, so it shows only in the 2 px band that belongs to neither. */ ::view-transition-group(root) { background: var(--gradient-foil); } ::view-transition-old(root) { animation: cf-rake-out var(--duration-slow) var(--ease-out) both; } ::view-transition-new(root) { animation: cf-rake-in var(--duration-slow) var(--ease-out) both; } } /* One parallelogram, slid from one side to the other. The incoming page keeps what is behind the edge; the outgoing page keeps what is ahead of it, --stroke-2 further on, and that offset is the contour. */ @keyframes cf-rake-in { from { clip-path: polygon(0 0, 0 0, calc(-1 * var(--angle-a-run)) 100%, 0 100%); } to { clip-path: polygon(0 0, calc(100% + var(--angle-a-run)) 0, 100% 100%, 0 100%); } } @keyframes cf-rake-out { from { clip-path: polygon(var(--stroke-2) 0, 100% 0, 100% 100%, calc(var(--stroke-2) - var(--angle-a-run)) 100%); } to { clip-path: polygon(calc(100% + var(--stroke-2) + var(--angle-a-run)) 0, 100% 0, 100% 100%, calc(100% + var(--stroke-2)) 100%); } }
Two polygon()s with the same vertex count interpolate vertex by vertex, so
the edge stays straight and stays at 63.43° at every frame — the shape is translated, not
morphed. This is also why the gap stays exactly --stroke-2 wide the whole
way across: both clips carry the same offset in the same units, so it cannot drift.
Every way out
This is a decoration on top of a navigation that already worked, and it is written so that every path away from it lands on the plain navigation rather than on a broken one.
| Condition | What happens |
|---|---|
| No support (Firefox, Safari < 18.2) | @view-transition and the ::view-transition-* rules are unknown CSS and are dropped. Instant navigation. |
prefers-reduced-motion: reduce |
The opt-in never applies. There is no 1 ms version of a wipe worth having, so there is no transition at all — the reader gets the instant navigation they effectively asked for. |
Cross-origin, or a POST, or a scripted location assignment |
The browser does not start a transition. Deliberate, and outside our control. |
| A slow response | The browser abandons the transition if the new document is not renderable within 4 s of the navigation starting, and navigates plainly. Nothing to handle; nothing half-drawn is left behind. |
| A same-page fragment link | Not a navigation the opt-in covers. Anchors inside a page behave exactly as before. |
| JavaScript off | Irrelevant. There is no script involved, on either page. |
A view-transition-name makes its element a backdrop root,
and backdrop-filter only samples what is painted below it inside its own
backdrop root. Naming .cf-nav therefore left .cf-nav::before
— the navigation's frosted sheet — with an empty backdrop, and the glass stopped
blurring anything, on every page, at all times. It was not a subtle loss: measured on
the landing page with a 6 px stripe pattern behind the bar, the mean gradient inside
the bar was 15.99 against 16.07 on the bare artwork
beside it. No blur whatsoever. With the name removed, 4.83.
The two plates carry it instead. They are what the eye actually tracks while the bar holds still — black rectangles with hard edges — and neither contains a frosted surface, so naming them costs nothing. The sheet stays in the root snapshot, where its backdrop is the page and its blur is live. A transient benefit paid for with a permanent one is the wrong trade, and this is the general shape of it: name the opaque things, never the material. → Materials
body > .cf-nav, or a future component
claims a name that already exists, the symptom is that the transition silently stops
happening on that page while continuing to work everywhere else. Check the console
before assuming the CSS moved.
Rules
Do
- Name chrome that is identical across pages, so it holds still.
- Keep the edge on
--angle-aand the run on--angle-a-run. - Let the light come from
--gradient-foilthrough a gap, not from a coloured border. - Put the opt-in inside
prefers-reduced-motion: no-preference, always. - Reset
mix-blend-modebefore replacing the default cross-fade with anything opaque. - Check that a name is unique in every document that can carry the element.
Don't
- Add a second transition for a second kind of link. One site, one gesture.
- Name a component instance inside
.docs-demo— a specimen is content. - Name something that fills the viewport: the reader taps and nothing answers.
- Reach for a duration outside the two speeds. This is 480 ms because it is light crossing a surface.
- Introduce a fifth angle. Both isometric angles were available; the shallow one does not sweep.
- Add a script to make it work in Firefox. It is a decoration, and it will arrive.
What this does not do yet
The obvious next move is the one this system deliberately has not made: morphing a
specific element into the page it opens — a blog cell into the article's header,
a team portrait into a profile. Cross-document, that needs the clicked element to be
given a name on the way out and the matching element to be given the same name on the
way in, which is a pageswap and a pagereveal listener, which is
a third shipping script. The system currently ships two, one of which is legally
required and one of which is optional and additive, and that ratio is worth protecting.
When it is done it should be one small file that refuses to run unless the pair actually
matches — the same shape as cf-stream.js.
Also unclaimed: nothing yet uses view-transition-class, which would let a
family of named elements share one animation without each one repeating it. There is no
family yet — three names, three behaviours. Worth remembering when there is a fourth.