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.

If nothing happens, nothing is wrong. Cross-document view transitions are in Chromium 126+ and Safari 18.2+; Firefox does not have them yet. Where they are missing, both the at-rule and the pseudo-element rules are unknown CSS and are dropped, and navigation is instant — which is exactly what this site did before. The same is true if you have asked for reduced motion: the opt-in is inside 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.

t 0.1
t 0.5
t 0.9

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°

RUN = H / 2 H 63.43° · --angle-a

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.

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

ConditionWhat 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.
The bar cannot carry the name, and it cost a working material to learn.

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

The one thing that can break it is a duplicate name. Not a partial failure — the browser abandons the whole transition and reports it. If a page ever renders two elements matching 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-a and the run on --angle-a-run.
  • Let the light come from --gradient-foil through a gap, not from a coloured border.
  • Put the opt-in inside prefers-reduced-motion: no-preference, always.
  • Reset mix-blend-mode before 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.