Foundations · 10

Motion

Two speeds. UI responds immediately and briefly; isometric objects turn slowly and come to rest. There is nothing in between.

Durations

Hover the tiles — each object tips into the isometry, at a different duration.

120 ms · --duration-fast
240 ms · --duration-base
480 ms · --duration-slow
1200 ms · --duration-scene
TokenDurationUsed forIn the CSS today
--duration-fast120 mshover, focus, colour change, damping a measured value15 rules
--duration-base240 msthe control itself responding — arrow, button, menu11 rules
--duration-slow480 msa reveal, or light crossing a surface8 rules
--duration-scene1200 msa full object build, if one is ever driven by timenone yet

The last column is the honest one, and it is counted by rule rather than by var() occurrence — a rule naming a token twice is one consumer, not two. Only --duration-scene has none.

That column has now gone stale three times, and it is still the only part of this page that can rot without anything rendering wrong. The first time, three of the four read 8, 6 and 4 against stylesheets holding 8, 8 and 5. The second time — found while the hero's still switch was being added, which is itself a new --duration-fast consumer — --duration-base published 8 against 10 in the code. The third time was the command's own arrival: it landed publishing 10 and 10 against 12 and 11, because several branches each adding a transition merged in the same hour and the number was written before the last of them. The fourth was the line of sight arriving: two of its three new --duration-fast consumers were its own damping, and the third had merged from another branch while it was being written. That is the failure mode the command exists for and cannot itself prevent — a number is only current as of the commit that wrote it. Run it, do not read it.
# from design-system/ — counts RULES, not var() occurrences
python3 -c "
import re
css = ''.join(open('assets/css/'+f).read() for f in ['tokens.css','base.css','components.css'])
blocks = re.findall(r'\{([^{}]*)\}', re.sub(r'/\*.*?\*/', '', css, flags=re.S))
for t in ['fast','base','slow','scene']:
    print('--duration-'+t, sum(1 for b in blocks if 'var(--duration-'+t+')' in b))
"

Comments are stripped first, because this page and the stylesheets both discuss these tokens in prose and a naive grep counts the discussion. Blocks are matched rather than lines, because a rule naming a token twice is one consumer, not two.cf-btn--glass names --duration-base in two of its three transition legs and is one rule. The declaration in tokens.css that defines each token is not a var() and never enters the count.

480 ms carries two different kinds of motion, and the distinction is worth keeping. Six of its eight consumers are light crossing a surface — the specular on each of the two lit buttons, the accordion row and blog cell each sliding --sheen-panel by background-position, and the two clip animations of the page transition, which are the same gesture at the scale of a whole page: one raked edge, one foil-lit contour, crossing the viewport. The other two are a panel opening: .cf-accordion__item::details-content, and the mobile navigation menu, whose clip edge travels down the panel to draw it out from under the bar. That looks like a counterexample to "the control responds fast" and is not one: a reveal is not the control answering a click, it is content arriving, and 480 ms is this token's stated purpose. What belongs at --duration-base is the control's own acknowledgement — the arrow stepping, the border changing. The glass button does both at once, which is why it names two durations in one rule.

The two panel reveals deliberately share a curve as well as a duration. --ease-in-out's entry in the table below — states that toggle back and forth — describes the menu almost exactly, and it is still the one curve with no consumer. It was left that way on purpose: the accordion got to --ease-standard first, and two panels opening in one system should not move on two different curves for a distinction only their stylesheets can see.

--duration-scene genuinely has no consumer, and it is worth knowing why rather than assuming: the isometric assembly is driven by scroll range, not by a duration. Its animation-duration is auto, and how long it takes depends entirely on how fast you scroll. So --duration-scene describes the intent of that motion without being the mechanism behind it. It is kept because the two-speed rule is a design decision, not an implementation detail — but it should not be cited as evidence that something on the site takes 1200 ms.

Curves

TokenValueUsed for
--ease-standardcubic-bezier(.2, 0, 0, 1)the default — quick start, soft arrival
--ease-outcubic-bezier(0, 0, .2, 1)objects that fly in and stay — the whole isometric assembly
--ease-in-outcubic-bezier(.4, 0, .2, 1)states that toggle back and forth — no consumer yet

All three start or end at 0 or 1 and never overshoot, which is what keeps the brand from springing. There is no elastic curve in the system and there should not be one.

Principles

Do

  • Move along the brand angles — isometric objects travel on 26.57°, not straight down.
  • Let lime appear through movement: an edge lights up as it is reached.
  • Shift the arrow 4 px right on hover. That is the standard feedback.
  • Give :focus-visible whatever :hover gets. Every state pair in components.css names both.
  • On scroll, animate position and opacity only — never scale an object. The one exception is a straight stroke that grows from one end, which is a scale about that end and the only draw that survives vector-effect: non-scaling-stroke; scripts/check-iso-motion.py enforces that it is done that way and not with a dash.
  • Keep the whole of it under the hand: three pixels of travel per pixel of scroll, at the curve's steepest. → A part may not outrun the hand

Don't

  • No parallax on text.
  • No bounce or elastic curves — the brand does not spring.
  • No infinite loops other than the hero artwork.
  • No animation that shifts content while the page loads.
  • Never let the focus ring stand in for a hover affordance. The ring says where you are; the affordance says what this does.
“No infinite loops other than the hero artwork” is the rule above, and the landing page shipped a hundred and twenty-eight of them. It is the one entry in that list with no cost you can see: a loop on the document clock renders correctly in every frame, and every screenshot this repository takes is one frame. What it does instead is stop the page ever going still. A view() timeline is free while nobody scrolls — no scroll, no frame, no work, which is why a page carrying eight hundred of them sits at zero. animation-timeline: auto with infinite is the opposite: it runs for the life of the tab, on screen, off screen, or fifteen thousand pixels away. Measured at 1440 × 900, the landing page loaded and then left completely alone — no scrolling, no pointer, no keyboard — spent 1 351 ms of style recalculation in five seconds, one recalc per frame at 60 Hz, and 2 246 ms of total task time: forty-five per cent of the main thread. At act 5, with the figure fifteen thousand pixels behind the reader, it measured the same. The count is not what costs and neither is the property: one transform animation on a single empty 4 × 4 px div measured 1 362 ms over the same five seconds, and twenty-six shimmering leaves cost 1 375 ms against one leaf’s 1 150. What the page pays for is producing frames at all. So the only lever is time, and a loop that must exist has to be able to stop. assets/js/cf-idle.js is that lever — an IntersectionObserver over a box marked data-cf-idle, a viewport of margin on each side, and animation-play-state: paused while it is away, which holds each reading at the value it had instead of restarting the field in unison on arrival. scripts/check-idle-motion.py reads the perpetual animations out of the shipping stylesheets rather than listing them, so a new one enters this rule by existing.

The isometric assembly

The first of the two composed pieces of motion in the system — a line drawn by a light is the other — and it carries no script at all. An isometric object assembles as it scrolls into view: the body travels in along 26.57°, the dashed reference geometry resolves after it, the signal line draws itself, and lime arrives last. Scroll the process card or the landing page to see it — the statement figure right under the hero is the first one you meet.

Put .cf-iso on the <svg> and give each element the part it plays. Nothing else is needed — no script, no wrapper, no data attribute.

The two halves are opt-in separately, and that is the point. .cf-iso on the <svg> buys the drawing rules — a true 1 px contour at every width, and dash patterns pinned to the --dash-* px values instead of scaling with the drawing. The part classes on the children buy the assembly. A figure can take the first without the second, and exactly one does: the Über uns page-header figure is on screen the moment the page loads and has nothing to reveal.

Every figure in the shipping tree, and which half each one takes — the eight objects Illustration > Where illustrations go counts, plus the three value-row marks, which are marks rather than objects and are counted there as neither. Seven of the eight objects assemble; the one that does not is the only one that is above the fold. That is the whole rule — an object the reader scrolls to assembles, and an object already on screen when the page loads has nothing to reveal.

This table said the three value-row marks do not assemble, and they have assembled since the release that redrew them. The exemption it recorded was real when it was written: the marks were three pairs of circles, and the argument was that a mark small enough to be a glyph would only make noise trying to arrive. Then they were rebuilt as constructed objects — bodies as 2:1 ground discs, chevron arms as whole lattice steps, the wheel measured off the mockup by radial profile — and the rebuild gave all three .cf-iso__scene and gave components.css a deliberate --iso-travel: 4 to go with it. Nothing was wrong on screen; only this page was. The rebuilt marks are objects the reader scrolls to, so the system's own rule covers them and the third clause was deleted rather than re-argued. The travel it needed is the same rule the section below states, which is what makes the two consistent: 160 / 40.
FigureContour rulesAssembles
landing page · the four process objectsyesyes — built part by part; on a wide screen the section pins and the build is scrubbed off the track instead, see A pinned track
landing page · statement figureyesyes — axis, then rays, then the lime centre
404 page · the level row with its socket lityesyes
blog article · the plotyesyes — and it is the one that is not aria-hidden, so it carries role="img" and a <title>
Über uns · page-header figureyesno — above the fold
Über uns · value-row marks (×3)yesyes — scene only; they carry no ghost, light, trace or node
PartWhat it isWhat it doesRange
.cf-iso__scenethe whole objecttravels in on 26.57° — see below — and fades up. The only thing that moves. Silent under .cf-iso--build.cover 5–30 %
.cf-iso__formsolid contoured bodynothing on its own; it rides the scene. Under .cf-iso--build it arrives on its own stage — see below.— / cover 5–22 % + 7 % per stage
.cf-iso__ghostdashed reference geometryfades up after the body it belongs tocover 20–40 %
.cf-iso__orbita ghost that circulatesfades up with the other ghosts, and keeps turning after them — see belowcover 20–40 % fading, 20–50 % turning
.cf-iso__tracethe signal reaching the object — one strokedraws itself, edge of the crop to edge of the crop — see belowcover 18–45 %, or a window inside it
.cf-iso__lightthe single lime-gradient elementits fill comes up last. Light arrives; it does not fly in.cover 30–48 %
.cf-iso__nodeconstruction pointsfade up last of allcover 35–50 %

A build happens where the eye is

This page used to state the opposite of the rule, and the ranges above are still the ones it stated. The sentence here read: "Everything is finished by the time the object reaches the middle of the viewport, so the illustration is never caught half-built while it is being read. The last range ends at cover 50 %, which is exactly that moment." Both halves are accurate about the numbers and the conclusion is backwards. A reader who scrolls at reading pace meets a finished drawing: the build ran, in full, in the strip of viewport below where anyone looks.

A cover range opens the instant the element's first pixel crosses the viewport bottom and closes when its last pixel leaves the top, so it spans vh + h; the element is centred at (h + vh) / 2 into that span. Both the element's height and the viewport's cancel: the moment a figure sits centred is cover 50 %, exactly, at every viewport. That is arithmetic and not a measurement — confirmed at 1440 × 900, 1280 × 800 and 1920 × 1080 — and it is why "ends by cover 50 %" and "is over before it is looked at" are the same sentence.

The rule is therefore the other way round: an assembly must still be visibly running when its figure sits in the middle third of the viewport. The acceptance test is one position and one number — scroll so the figure's centre is at the viewport's centre, sample the rendered state, and require it to read no more than about 60 % complete. Measure the rendered state and not the fraction of the range spent: these animations carry --ease-out, so the visual state runs ahead of the linear range position, and a first retime that put a figure two thirds through its window still read 78 % built at the centred position.

Measure it as ink where the assembly is a drawing — the sum of each stroke's drawn length, against the same sum finished — rather than as a mean over parts. A mean over parts is a different quantity every time the drawing is redrawn: the landing page's flow was ten strokes and became thirty-seven, and the same envelope that read 67 % of its ink at the centred position under the first geometry read 78 % under the second while its unweighted part mean barely moved. scripts/check-build-arrival.py counts ink and holds the ceiling, so this rule is enforced rather than merely written here.

Held so far: the landing page's two entry-phase assemblies — the statement figure and the flow — and both pass. The default .cf-iso ranges in the table above are the open tranche: cover 5–30 / 20–40 / 18–45 / 30–48 / 35–50 all close at or before cover 50 %, so every figure that runs them is finished at the moment it is centred. They are shared by every pattern page and retiming them is a change to the whole system rather than to one page, so it is stated here rather than done quietly on the way past.

A part may not outrun the hand

The rule above is about when a build happens. This one is about how fast, and it is the same finding one step out: an assembly driven by scroll range rather than by a duration has no speed of its own, so the reader's hand is the transport — and how far a part moves for each pixel the page moves is an authored quantity like any other. Nothing had ever been held to it, and one animation on the landing page was thirteen times over everything else there.

The ceiling is three pixels of travel for each pixel of scroll, at the timing function's steepest. At 3 to 1 a 100 px wheel notch throws a part a third of a screen, which is the edge of reading as a scrub rather than as a cut. A scatter is the one kind of motion here allowed to move faster than the hand at all; everything that builds is under 1.2.

It is arithmetic and not a measurement wherever the range is exit, because the exit phase spans the subject's own height and a drawing's travel vectors are in the same viewBox units as that height — so the render scale cancels and the ratio is the same number at every viewport. The statement figure's farthest part travels hypot(393.12, 196.56) = 439.5 units in a window of 0.40 × 420 = 168, which is 2.62. Then the curve multiplies it: an eased curve's steepest slope is the factor by which the rendered state outruns the linear range position, and for --ease-outcubic-bezier(0, 0, .2, 1) — that factor is exactly 5, at t = 0, which is where an exit starts. 13.08, and the part was 83 % gone by the middle of its own window.

How it got there is worth as much as the number. animation-name is a replacement and not an addition, so a rule that adds a second animation has to restate every list on it in full — and that rule restated name, range and fill-mode and not animation-timing-function, so the exit silently took the --ease-out the isometric family sets for its fades. The curve is the one list it is easiest to forget, because omitting it is the only one that renders as something rather than as nothing. The fix is animation-timing-function: var(--ease-out), linear: the arrival keeps the token that is for objects that fly in and stay, and the departure takes the same linear the flow's two passes take, for the reason stated there — scroll position IS the draw. scripts/check-scrub-rate.py holds the ceiling and catches both shapes of the omission, the missing list and the short one that cycles.

Held: every part of the statement figure that carries an exit vector, in both files that ship the drawing. The cover and contain families are not held, and cannot be by arithmetic: their phases span vh + h and pinH − vh, so their rate depends on the viewport and a static script has no number to state. They were measured in Chromium instead — the highest is card 01's tallest build part at 1.14 CSS px per px of scroll, 155 units over 374 px of the pinned track.

An object that is assembled, not delivered

The default assembly moves the whole scene: the finished object slides in along 26.57° and its layers resolve on top of it. That is right for a figure that illustrates a sentence, and wrong for the four objects in Was wir machen, which are the sentence. A telescope that arrives already extended has not shown you a telescope; it has shown you a picture of one.

.cf-iso--build on the <svg> turns that round. The scene holds still and the parts arrive — each .cf-iso__form on its own stage, out of its own direction. Two rules keep it from becoming decoration.

1 · A part travels only along an axis the object already contains, and only as far as the drawing says. A telescoping section moves on the shaft axis, which in 2:1 isometry is the screen vertical. A plate on a rack moves on the rack axis, by one slot pitch. A radial graduation moves out along its own radius. Nothing is given a direction that is not already drawn in the geometry — which is why --build-dx and --build-dy are authored per part, in viewBox units, rather than derived from one token. The 98 and 238 on card 01 are not a motion decision; they are the two extensions measured off the source vector, and fully retracted they put all three plates concentric on the same line, which is what a closed telescope looks like from above. Both default to 0, so a part with nowhere to come from resolves in place. That is the common case, and it should be.

2 · Stage order is construction order, not paint order. --stage is an index and it is deliberately independent of where the element sits in the markup — card 04's meridian is the second element in the file and the first to arrive. Nothing is reordered to animate it, so occlusion, paint servers and the diff against assets/source/illustrations/ are all exactly what they were. On card 01 the paint order then does the occlusion for free: the inner walls are drawn before the middle ones and those before the outer, so a section rising through the one below it is hidden by it until it clears the mouth.

<svg class="cf-iso cf-iso--build" …>
  <!-- stage 0, no travel: it is already home -->
  <path class="cf-iso__form" …>
  <!-- stage 1, retracted 98 units down the shaft -->
  <path class="cf-iso__form" style="--stage:1;--build-dy:98" …>
</svg>
PartUnder .cf-iso--buildRange
.cf-iso__scenedoes not animate at all — every child starts at opacity 0, so the scene has nothing left to fade and nowhere to arrive from
.cf-iso__ghostfirst on screen. A ghost is the object's future state, and the parts arrive out of exactly where the ghosts are, so a ghost that fades up after the part it predicts is the drawing told backwardscover 2–14 %
.cf-iso__orbitexcluded from that retiming — an orbit is a ghost that turns, and its turn is written against 20–50 %unchanged
.cf-iso__formarrives on its stage, from --build-dx / --build-dycover 5–22 %, +7 % per stage
.cf-iso__lighta part like any other while the object is being built, so it takes a stage and travels with the section it belongs to — card 01's lime plate is the top of the inner shaft and cannot stay behind while the shaft rises. Its fill still comes up last.arrival on its stage; fill cover 30–48 %
.cf-iso__nodenot staged. Construction points are added to a finished object, at its finished position, which is what they meanunchanged

Stages overlap by ten points of the seventeen each one runs, which is what makes this read as one assembly rather than four events. Four stages fit before lime comes up at 30 %, and the whole build is settled by 43 % — inside the same cover 50 % everything else finishes at.

A part moves with translate, not with transform, and that is not a style choice. Half the parts that build carry a transform attribute: card 03's three ellipses and card 04's sphere are all rotate(-90 …), which on the ellipses is the shape and on the circle is the paint server's axis. An SVG transform attribute is a presentation attribute for the transform property, so a keyframe that sets transform does not compose with it — it replaces it. Card 03's apertures would snap from a tall ellipse to a wide one for the length of the build, and card 04's orbit fade would swing a quarter turn, which is the same failure the orbit section below is written to prevent. The individual translate property is applied before transform and leaves it alone.

@keyframes cf-iso-build {
  from {
    translate: calc(var(--build-dx, 0) * 1px) calc(var(--build-dy, 0) * 1px);
    opacity: 0;
  }
  to { translate: 0 0; opacity: 1; }
}

0 0 rather than none at the far end, though the two compute the same thing and render identically here: a length pair matches the length pair it is interpolating from, and needs no engine to agree that none means zeros.

The scene's own cf-iso-arrive can keep using transform because it moves a bare <g> that carries no attribute of its own. Units are viewBox units — 1 px on an SVG element is one user unit — the same basis --iso-travel uses and the same basis the distances were measured in.

All of it sits inside the same @supports / @media (prefers-reduced-motion: no-preference) block as the rest of the assembly, and --stage and the two travel properties are inert on their own. Without scroll-driven animations, under reduced motion, or on paper, a built object is simply the finished drawing — and that is measured rather than asserted: rendered under prefers-reduced-motion: reduce, the four built cards are pixel-identical to the same four before the build existed.

Measure it there and not in the settled animated state, because animating an element at all changes how Chromium rasterises its contours — a sub-pixel shift along a hairline, and it means no animated object lands on its own still render to the pixel. That cost is not the build's; the version that moved the whole scene paid it too, and on every card the build pays less:

Card, 1440×900 @2×Reduced motion, built vs. un-builtBuilt: animated vs. stillWhole-scene arrival: animated vs. still
01 Discovery0 of 495,61624,92525,379
02 Datenfundament0 of 495,61634,98236,058
03 Weniger Ausfälle0 of 495,61625,82634,896
04 Mehr Leistung0 of 495,61625,27025,483

The shape of that check is worth keeping: compare under reduced motion, where the answer has to be zero, and read an animated figure only against the same figure taken the same way before the change. A settled-state diff against a still render will always be a five-figure number and will never mean what it looks like.

An orbit turns, and it turns by moving its dashes

Card 04 carries three dashed rings around its sphere. They are the only geometry in the system that is drawn as motion, and until now they were also the only thing on the page that said “this is circulating” and then held perfectly still. They now turn as the object assembles and settle when everything else does — the brand's rule for anything spatial, which is that it turns slowly and then stops.

The obvious way to turn a ring is to rotate it, and that is the wrong way here. All three orbits are stroked with userSpaceOnUse gradients, and a paint server is resolved in the user space in force where it is referenced — which includes the element's own transform. Rotating the ring rotates its fade with it, so the designer's “solid on the left, dissolving to the right” would sweep round the object and spend its solid half across the top of the lime disc, which is the one place the eye is meant to land. That is not a hypothetical: it is the failure Process Card already measures, from the release in which a rotate(-90) was dropped as a no-op.

stroke-dashoffset touches no paint server at all, so the fade axis stays where the source vector put it and only the dashes move — which is what an orbit looks like anyway. The ring does not swing; the things on it go round.

@keyframes cf-iso-orbit {
  from { stroke-dashoffset: var(--iso-orbit-travel); }
  to   { stroke-dashoffset: 0; }
}

The travel is in screen pixels, and it must be a whole number of dashes

--iso-travel is in viewBox units because it is a transform. This one is in screen pixels, and that is forced rather than inconsistent: under the non-scaling-stroke that .cf-iso puts on every contour, both stroke-dasharray and stroke-dashoffset are measured in device space. Measured on the shipped card 04 at its 352 px render, the rendered ring is pixel-identical at these offsets:

stroke-dashoffsetrenders as
0 pxidentical
5 px
2.5 pxidentical
12.5 px

The period is exactly 1 + 4 = 5 px — the --dash-1-4 the orbits are drawn in. So the travel has to be a whole multiple of 5, and that is the whole reason it is a token rather than a number in a keyframe. A dashed ring is periodic: it settles on the phase the designer drew only if it stops an exact number of dashes from where it started. The shipped 60 px is twelve dashes. 63 would leave every orbit three fifths of a dash off the source vector for good — a drift nothing reports, and one a diff against assets/source/illustrations/ could never show, because it lives in the rendered phase and not in the markup.

Because both ends of the animation sit on that phase, so does the both fill held before the range opens. Measured against the same page built without the animation at all:

StatePixels differing from the un-animated build
settled, past cover 50 %0 of 323,761
mid-turn, around cover 25 %4,317 of 238,980 (1.81 %)

The object a reader actually reads is byte-for-byte the one the designer drew; the turn exists only on the way in. And the ink on the ring is conserved while it turns — left-to-right ink balance mid-turn moves from 0.863 to 0.864 — which is the fade axis staying put, measured rather than asserted.

One value drives all three rings, so they share a linear speed and therefore not an angular one: the inner ring turns through more of itself than the outer one over the same scroll. That is what stops three concentric rings reading as one rigid body being spun.

This is a paint-only animation, not a composited one. Transform and opacity are the cheap pair; stroke-dashoffset repaints the stroke each frame. It is admitted here because it is not a new class of expense — .cf-iso__trace has animated the same property on every card since the assembly was written, and three dashed hairlines running only while their object crosses the viewport is the same order of work again. It causes no layout and no reflow.

How far it travels

The distance is one token, --iso-travel, and it is the rise. The run is always twice it, which is what makes the 26.57°; writing it once keeps the 2:1 provable rather than leaving two hand-written numbers that happen to agree.

@keyframes cf-iso-arrive {
  from { transform: translate(calc(var(--iso-travel) * -2px), calc(var(--iso-travel) * -1px)); opacity: 0; }
  to   { transform: translate(0, 0); opacity: 1; }
}

The value is in viewBox units, not screen pixels — a CSS transform on an element inside an SVG is in that SVG's user coordinate system. On the 640-unit drawings that is 2.5 % of the object down and 5 % across, so the object travels a fixed fraction of its own size rather than a fixed number of pixels. Measured: 17.6 × 8.8 px where the illustration renders at 352 px, 9.2 × 4.6 px at a 375 px viewport, where it renders at 184. The angle is what is fixed; the distance is proportional. That is deliberate — the same drawing at two sizes should move the same amount relative to itself, not skid further at the small end.

The second of those two numbers used to read 13.4 × 6.7, and it was right when it was written. It is derived from the rendered size, and the rendered size on a phone changed the day the process figure got the height cap it had always needed — the drawing was overflowing its panel, and 268 px of it was 268 px of a box only 249 px tall. Nothing about the motion moved. Every measured pixel figure on this page is downstream of a layout number somewhere else, which is the same failure mode as the duration table above and worth the same treatment: re-measure, do not re-read. → Illustration > Rendering

The catch is the other half of that sentence: because the unit is the drawing's own, a drawing with a different viewBox travels a different fraction on the same token. The statement figure is 480 units, so the shipped 16 would have moved it 3.33 % — measured at 35.8 × 17.9 px against the process objects' 17.6 × 8.8 px, roughly twice as far while being only 1.5× the size. It therefore overrides the token to 12, because 12/480 is exactly 16/640.

.cf-statement .cf-iso { --iso-travel: 12; }

The rule: 2.5 % of the viewBox. Any new illustration whose viewBox is not 640 units sets --iso-travel to viewBox / 40 on itself. The ratio is what the system holds constant, not the number.

And this one is now run rather than read. It has broken twice in the life of this page — once when four objects were recropped within an hour of their values being written, once when the statement figure's value was scoped to a single page's stylesheet — and neither break rendered anything wrong. That is the same failure mode as the duration table at the top of this page, and it gets the same treatment the space scale got: a check, in CI, on every push.

# from the repo root
python3 scripts/check-iso-motion.py

It resolves each figure's travel the way the cascade does — inline style, then the component-keyed rules in components.css, then the :root default — and compares it against that drawing's own frame. It checks five more things on the same principle, every one of them invisible in a screenshot and countable in a file: the orbit's travel is a whole number of dashes, every trace is normalised and none has non-scaling-stroke put back on it, every orbit is also a ghost, no object carries two lights, and every animation-timeline declaration sits in a @media that names screen — which is the rule the navigation rim broke while it was already written down two sections below.

The oklab waypoint on every lime leg is the one invariant of this kind that this script does not check, and the reason is worth stating: scripts/check-gradient-family.py already recomputes that waypoint's offset and its colour from the oklab path rather than comparing them against a literal, which is strictly the stronger claim. Two scripts asserting one rule to two standards is the drift these scripts exist to stop. → Colour

A rule nobody had checked since it was written. Counted across the shipping tree, seven of the fifteen objects have a frame that is not 640 and only the statement figure was setting the token. The four on patterns/expertise.html are cropped to their own objects — 657.6, 642.4, 653.6 and 625.6 units wide — so on the default they arrived from between 4.90 % and 5.19 % of their own width, four objects at four distances, no two of them the same. The three figures in the Über uns value table are 160 units and are the sharp case: 16 there is a fifth of the drawing's own width. All seven now carry their own value.

Where it lives depends on whether the number belongs to a place or to a drawing. The statement figure and the value table have one frame per slot, so the value is a rule in CSS keyed on the component. The four expertise objects have four different frames in one component, so it is an inline custom property on the svg — the same standing --trace-from has, and for the same reason: the number is a fact about that one drawing's own coordinate window and there is no class it could hang on that the other three would not inherit.

.cf-value-row__figure .cf-iso { --iso-travel: 4; }   /* 160 / 40 */
“Keyed on the component” is doing work in that sentence, and the statement figure is where it was not. Its 12 lived in patterns/landing-page.html's own <style> block, keyed on .lp-statement-figure — a hook that exists on exactly one page. The identical 480-unit drawing on the component's own documentation page matched no such selector, so it sat on the 640-frame default and arrived from 3.33 % of its own width: the specimen showed the motion a third further than the thing it is a specimen of. The rule moved into components.css as .cf-statement .cf-iso, which both copies match. A value that is a fact about a drawing cannot be scoped to a page — the same drawing turns up on at least two.

How it is driven

Native scroll-driven animation — view-timeline-name on the SVG and animation-timeline on the parts. No IntersectionObserver, no script, and no third-party library.

EngineStatus
Chrome / Edge 115+shipped, July 2023
Safari 26+shipped, September 2025
Firefoxas of 152, still behind layout.css.scroll-driven-animations.enabled in stable; on by default in Nightly, and 155 is the first version listed as shipping it

Firefox users therefore get the finished object rather than the assembly, which is a perfectly good outcome and the reason the fallback direction matters more than the support number. Do not quote a traffic percentage for this — it moves, and the decision does not depend on it: the assembly is an enhancement, and the page is complete without it.

The whole block sits inside @supports (animation-timeline: view()), and the fallback runs in the honest direction: a browser without support draws the finished object. Nothing starts at opacity: 0 waiting to be rescued, so there is no way for the illustration to end up invisible. Only transform, opacity, fill-opacity and stroke-dashoffset animate, so nothing reflows — no layout property is touched and the page never shifts under the reader.

Half of that list is composited and half is not, which is worth being straight about. transform and opacity on .cf-iso__scene are handed to the compositor and cost nothing per frame. fill-opacity and stroke-dashoffset are not compositable in any engine: each frame repaints the element they are on. That is affordable here and only here, because it is one gradient face and one hairline per object, inside a drawing a few hundred pixels square — and because both animations are over by the time the object reaches the middle of the viewport. It is not a licence to put a stroke-dashoffset animation on a whole illustration.

The line-drawing uses pathLength="1" in the markup, which normalises the path to a length of 1 and makes the dash maths the same for every path regardless of how long it actually is:

.cf-iso__trace { stroke-dasharray: 1; --trace-from: 1; --trace-to: 0; }
@keyframes cf-iso-draw {
  from { stroke-dashoffset: var(--trace-from); }  /* 1 = nothing drawn */
  to   { stroke-dashoffset: var(--trace-to); }    /* 0 = fully drawn  */
}

Why the two ends are authored

A trace is a signal arriving from somewhere off-stage, so its path usually runs past the edge of the drawing — and the frame is a crop, not a bounding box. But pathLength normalises against the drawn length, not the visible one, so every unit outside the crop spends scroll range on a line nobody can see. The range is honest; the picture is not.

Measured on the landing page, at the settled object — the fraction of each trace that is actually inside its crop, and what a plain 1 → 0 draw did with the range it was given:

TraceInside the cropWasNow
card 02, outgoing (to the right)54 % finished by cover 24 % — the last 21 points of its range drew off-stage --trace-to: .46
card 02, incoming (from the left)49 % nothing visible at all until cover 24 %, then half a line in one step --trace-from: .49
card 04, outgoing (lower left)88 % finished at cover 41 % --trace-to: .12
card 03, incoming100 % finished by cover 26.8 % — for a different reason, see below five traces, led

The two ends are therefore per-trace: a trace that leaves the crop stops drawing at the edge, and one that enters from outside starts at the edge. Both now spend the whole of cover 18 % → 45 % on the part of the line the reader can see, and the two traces on card 02 — which are one continuous signal passing through the object — finally advance together instead of one waiting for the other.

Only the endpoints count. A path that leaves the crop and comes back wastes no range at either end, so it is left alone: the gap reads as the signal passing behind the crop, which is what it is. The system had one apparent example of this — the specimen trace in Illustration, 16 % of its length outside the frame — and it turned out not to be one. It was three strokes in a single <path>, and the “gap in the middle” was the crop cutting the end of one of them. Split into three traces it is an ordinary --trace-from. Once a trace is one stroke, a genuine mid-path gap is a rare thing rather than the common case it looked like.

Measure against the settled object. While the scene is still arriving it carries the 26.57° offset, which moves the crop boundary by up to --iso-travel: card 02's outgoing trace measures 54 % settled and 61 % at the start of the travel.

A trace is one stroke

The crop is not the only thing that eats the range, and the second cause hid behind the first for as long as the table above read “card 03: 100 % inside the crop, correct already”. It was inside its crop and it was not correct. A dash pattern restarts at every subpath, while pathLength normalises the path as a whole — so on a path made of several strokes, every stroke draws its own first 1 − offset of the total, all at the same time, and the whole thing is finished the moment the longest stroke is.

Card 03's incoming arrow is five strokes in one <path>:

StrokeUser unitsFraction of the path
shaft112.500.3265
chevron at x 22877.370.2245
chevron at x 19377.370.2245
upper barb38.680.1123
lower barb38.680.1123

The longest is 0.3265 of 344.61 units, so the arrow was complete at stroke-dashoffset 0.6735 — cover 26.8 % of a range running to 45 %, with 18.19 of its 27 points spent on a picture that had stopped changing. Two thirds of the range, on the most-read illustration in the system. And it drew all five strokes at once, growing outward from five different points, which reads as a sketch scribbling itself rather than as a signal arriving.

So a trace is one stroke. Where the signal is drawn as several, each is its own trace and they are led along the direction of travel:

.cf-iso__trace { --trace-lead: 0%; --trace-span: 27%; }   /* the whole window */
.cf-iso__trace {
  animation-range:
    cover calc(18% + var(--trace-lead))
    cover calc(18% + var(--trace-lead) + var(--trace-span));
}

--trace-lead is how far into the window a stroke starts; --trace-span is how much of it that stroke spends drawing. Both are percentage points of cover, both default to the whole window, so a trace that is a single stroke resolves to cover 18 % → 45 % and nothing about the other three cards changed. Keep lead + span ≤ 27 % and the last stroke still lands on the light.

Card 03 now carries five traces on three leads — the far chevron at 0, the near one at 3 %, and the arrowhead's two barbs together with the shaft at 6 %, all on an 18 % span. Measured: cover 18–39 / 21–42 / 24–45, every stroke reaching offset 0 at the end of its own window and none before it. The signal travels left to right into the disc, which is the direction the designer drew it flying.

One change to the drawing came with it, and it is invisible at rest: the shaft is written M300 511.5H412.5 where the source vector has M412.5 511.5H300. Same line, same round caps, drawn from the arrowhead into the disc rather than out of it. A stroke's direction is not part of a static drawing; it is entirely a fact about the draw.

A calc() in a keyframe does not interpolate

The obvious way to write this is a semantic pair — how much is off-frame before, how much after — and to subtract in the keyframe: calc(1 - var(--trace-lead)). It compiles, it renders, and it does not animate. Chromium steps straight from the start value to the end value. Sampled across the range at cover 18 / 20 / 22 / 24 / 27 / 30 / 35 / 45 %, the drawn fraction went 0 · 0 · 0 · 0.54 · 0.54 · 0.54 · 0.54 · 0.54 — five samples of nothing, then the finished line.

Registering the two properties with @property as <number> does not rescue it; that was tried and gave the identical step. The calc() itself is what fails to interpolate on this property. So the custom properties are the animated values themselves, not an expression over them — which is why they are dash offsets rather than the friendlier "fraction off-frame", and why --trace-from is written as 1 − the fraction already behind it by hand at the call site. Any future keyframe in this system that wants a per-element value must take it whole.

The one place the contour rule has to give way

Every other stroke in an illustration carries vector-effect: non-scaling-stroke, which is what makes "1 px contour at every size" literally true. The trace is the exception, and it has to be.

Under non-scaling-stroke the dash pattern is measured in screen pixels, while pathLength normalises against the path's user-space length. The two disagree by exactly the render scale, so the draw is stretched by 1/scale and finishes early. Measured on a 640-unit drawing rendered at 352 px (scale 0.55), against what each offset is supposed to mean:

stroke-dashoffsetShould be drawnWith non-scaling-strokeStroked in user units
10 %0 %0 %
0.7525 %45 %25 %
0.550 %91 %50 %
0.2575 %100 %75 %
0100 %100 %100 %

The line was fully drawn by the time the offset reached 0.45, so the last 45 % of the trace's scroll range did nothing at all — and worse at small sizes, where the scale is lower and the dead stretch is longer. So .cf-iso__trace is excluded from the non-scaling-stroke rule and stroked in user units instead.

The weight that pays for it is a per-object number, not a constant

A user-unit stroke lands on screen at weight × render scale, and the scale belongs to the drawing's own frame — its viewBox width over the pixels it is rendered into. So the number that puts a trace back on the contour weight is viewBox width / rendered width, and it is different for every frame. It had been one literal, 2, serving five families at four scales:

FrameRender scaleWasNow
.cf-process__figure — the four cards0.551.10 px1.00 px
.ex-step__figure — Expertise0.78 – 0.861.55 – 1.71 px0.95 – 1.00 px
.ill-single — the specimen on this system's illustration page0.651.30 px1.00 px
.ill-frame — the reference strip0.19 – 0.250.38 – 0.50 px0.97 – 1.13 px

Measured across 375 / 768 / 1280 / 1920, the shipped trace ran from 0.38 to 1.71 CSS px. At the heavy end the signal was 71 % thicker than the object it arrives at — a fifth line type, in a system that sanctions four. At the light end it was 0.38 px, which composites to about 2.5:1 against CF-Grau, under the 3:1 this system holds a contour to. --trace-weight is that number, and it is declared where the object's size is declared — beside the max-width or the calc() it is the reciprocal of — for the same reason --iso-travel is written next to the viewBox it comes from. A weight parked in the illustration would be a second place to remember when a frame is resized.

Where a drawing is fluid rather than capped, no number is right, and that is stated rather than papered over. A process card between 256 and 554 px of card width is letterboxed by max-block-size into a box that grows with the card: scale 0.22 at a 320 px viewport and 0.53 at 608. That is a 2.4× ramp against a fixed multiplier, so the value that lands on 1 px at one end misses the other by the same 2.4×. Those bands keep the old constant — 0.45 to 1.06 CSS px on a phone — and the three that are pinned to a cap are exact. Getting the fluid half right needs the rendered box, and only the box knows how wide it is.

Under a CSS pixel at the small end is the price of a draw that is linear at every size, and it is still the right trade — a trace is a signal, not a structural contour. It is also the end with the device pixels to spend: no 375 px viewport is DPR 1, so 0.57 CSS px is 1.15 device pixels at DPR 2 and 1.7 at DPR 3, and the trace never actually thins below the hairline it is imitating.

If you add an illustration with a trace of its own, give the path pathLength="1" and the class, and set --trace-weight on the frame to its viewBox width over the width it renders at. Do not put non-scaling-stroke back on it.

A line drawn by a light

The second composed piece of motion, and the one the landing page is mostly built out of. A line in this system is not laid down finished. It is drawn twice: a lit front carrying the light family's ramp goes first, and the 1 px black contour lands behind it. The front is a fact about the drawing happening — it exists while a line is being drawn and it is gone by the time the line is there. What stands while the reader reads is black.

Scroll the figure below. Six strokes, drawn in the order the data reached them — the middle vertical, the base, the two sides, then the top rail spreading outward from the middle — each with its own lit twin six points ahead of it.

Six strokes, twelve passes, one relay — the lectern of Was wir machen

Three drawings ship it, on one pair of keyframes — a draw and a fade out. They differ in weight, in which phase they spend, and in what the front does once its own stroke has landed:

FrontDrawingWeightLeadThe fade
.lp-flow__light · pinned the root — every route out of the void 2 px over 1 px 6 points of a 45-point contain walk holds 2 points, then goes out over 6 — it ends 2 points after its contour lands
.lp-flow__light · in flow the same root, under the pin gate, on the drawing's own view() 2 px over 1 px 10 points of a 72-point contain walk holds 4 points, then goes out over 10 — it ends 4 points after its contour lands
.sp4-lit Wer wir sind — four edges per plate 6 px over 1 px 4 points of a 16-point edge window holds 6 points, then goes out over 8
.lp-frame__lit the lectern the process cards stand on 6 px over 1 px 6 points of a 36-point entry relay exactly the lead, opening as its own draw closes and out as the contour lands

Two strokes, one line

The pair is not two elements that happen to agree. Both carry the same --a, the same --u and the same --o — the start, the length in viewBox units, and the junction the stroke grows out of — and the front is offset from the contour by the stylesheet's single lead. Nothing else may differ. A pair that disagrees on any of the three is two lines pretending to be one, and it is invisible at both ends of the window: at rest they are one black hairline, and while they are drawing the eye reads the colour, not the geometry under it. scripts/check-relay-rate.py reads every lit stroke against its contour twin, and fails a front without a contour or a contour without a front.

The lights are drawn before the contours in document order, so the black paints over the colour. It cannot cover it — that is the whole reason the two weights differ — and what survives is a rim of ramp on each side of the black, until the fade takes it.

The weight is decided by the stroke, not by taste

The ramp's coloured leg is its first 32 %: lime to Glas, and CF-Grau for the rest. A front has to be wide enough that the leg can be seen under the contour that is painting over it. On the root a lit stroke is 20 to 110 units long and 2 px is enough. On a plate edge or a frame rail — the whole width of the drawing — 2 px of ramp under a 1 px contour is a gradient nobody can see, so both of those are 6 px, which leaves two and a half proud on each side.

The lead is a fraction of the run it leads

Six points on the root, four on a plate edge, six on the frame — and none of the three is borrowed from another. A lead is a proportion: the root's longest walk is 45 points of contain, an edge's window is 16, the frame's relay is 36 points of entry. The frame takes the root's six because its run is near enough the same length, not because the root has six.

And the lead is flat across the relay, not per stroke. The front runs six points ahead of the black through the whole chain rather than six points ahead within each stroke — which is what makes six strokes handing off to each other read as one travelling line instead of six lines each with a bright end.

The root is drawn by two tiers, and the proportion is what they share rather than the number. Under the pin gate there is no stage to hold the reader still, so the drawing is its own timeline subject and the act plays across the contain window in which the tree is whole on screen — 665 px at 390 × 844 against the pinned track's 5 760. Every window is the same algebra off a second rate, --flow-cf beside --flow-c, and the walk is 72 points instead of 45. Ten points of 72 and six of 45 are the same 0.4 units of the drawing's own depth: the front runs the same distance ahead of its own black in both, which is the only sense in which a lead can be carried across a change of rate. scripts/check-flow-chain.py holds one rate per tier, every family written off its tier's own, and both pairs of tails.

The draw is a scale, not a dash

The obvious mechanism is the one the trace uses: pathLength="1", stroke-dasharray: 1, animate the offset. It cannot work here and the failure is silent. A dash is measured in screen pixels while pathLength normalises in user units, so under vector-effect: non-scaling-stroke the two disagree by exactly the render scale. The frame is a 1000 × 500 viewBox stretched to the plate — 1278 × 639 at 1440 × 900 — so every stroke stopped at 1000 / 1278 = 78.2 % of itself and stayed there: the rails ended 278 px short of the right vertical, and the lectern the whole pinned stage rests on was never once a closed rectangle.

A straight stroke grown from one end is a scale about that end, and transform-box: fill-box puts the origin at each stroke's own junction. One scale: 0 → 1 serves all six; scaling the axis a stroke has no extent on is a no-op, so the markup says nothing new. The weight ignores the transform under non-scaling-stroke, so 6 px and 1 px stay 6 px and 1 px throughout.

.lp-frame__line { stroke: var(--border-strong); transform-origin: var(--o, 0 0); }
.lp-frame__lit  { stroke-width: 6; transform-origin: var(--o, 0 0); }

/* the contour: its own window, at the relay's one rate */
.lp-frame__line {
  animation-range: entry calc(var(--a) * 1%)
                   entry calc((var(--a) + var(--u) * var(--relay-rate)) * 1%);
}
/* the front: the same window, --frame-lead points earlier, and out again */
.lp-frame__lit {
  animation: lp-frame-draw linear, lp-flow-out linear;
  animation-range: entry calc((var(--a) - var(--frame-lead)) * 1%) ;
}

The light is gone before the reader arrives

This is the rule the other three exist to protect. Lime is one lit element per object (Colors › one per screen), and a front is not an exception to it — it is a thing that happens and then stops happening. Measured on the lectern at 1024, 1440 and 1920: at the moment the last hairline lands, every lit stroke is at opacity 0. No lime stands around the cards while they are being read. The frame's fade is exactly its lead for that reason and no other — entry 100 is contain 0, the moment the stage takes the scroll and the cards start building inside the frame, so nothing may still be going out when the pin takes over.

It follows that where nothing is driving the front, there is no front. The base tier paints the black hairline alone and the scroll-driven block turns the light on: reduced motion, no view-timeline support, print, and every width below the gate get the settled drawing, which is a black contour. A lime rectangle standing permanently around a photograph is not the settled state of anything here — see Reduced motion, where that is the same rule stated for the assembly.

Reduced motion

prefers-reduced-motion: reduce sets all four durations to 1 ms — states then switch instantly instead of animating. That is already in tokens.css and applies automatically to any component built on the tokens.

Durations do not help against animations that are driven by scroll or that loop forever, so those are switched off by hand in components.css. The whole isometric assembly is wrapped in @media screen and (prefers-reduced-motion: no-preference). The hero swaps its loop for the poster:

The preference is a default, not the mechanism. Everything on this page that respects prefers-reduced-motion is doing the right thing and none of it is a substitute for a control the reader can reach. See Hero artwork for the one place in this system where that distinction is a conformance requirement rather than a nicety.
.cf-hero__media img { display: none; }
@media (prefers-reduced-motion: reduce) {
  .cf-hero__media video { display: none; }
  .cf-hero__media img   { display: block; } /* still frame instead */
}

This means the hero needs both elements in the markup — a <video> with its poster, and an <img> of the same frame. poster alone is not enough: it disappears with the video.

Three ways to get the finished object

The assembly is written so that every path away from it lands on the same place — the object fully drawn. There is no state in which an illustration can end up invisible, because nothing starts at opacity: 0 except inside the block that also animates it back to 1.

ConditionWhat switches it off
No scroll-driven animation support@supports (animation-timeline: view())
Reduced motion(prefers-reduced-motion: no-preference)
Print and any paged mediumscreen and

The third one is easy to miss and was a real bug. A paged medium has no scroll, so a view timeline never advances: printing the landing page put whichever illustration happened to be at the current scroll position on the paper and left the other three blank. Scoping the assembly to screen is the whole fix — print then sees no animation at all and draws the finished object, exactly as an unsupporting browser does. Any scroll-driven animation added to this system must be scoped to screen for the same reason.

The other scroll-driven animations

A fourth condition switches a view timeline off, and unlike the three above it does so silently: a clipping ancestor. A view timeline resolves against its subject's nearest scroll container, and overflow: hidden makes an element one — whether or not anything ever scrolls inside it. Putting the glass button's rim pass inside .cf-hero, whose overflow: hidden is there only to crop the artwork, handed the timeline a box that never moves: Chromium reported a live ViewTimeline whose progress sat at 0.116 at every scroll position on the page. No error, no warning, and nothing on screen to distinguish it from an animation that had never been written — which is what makes this worth a warning rather than a sentence. The fix is overflow: clip, which crops without creating a scrollport, declared after hidden so a browser too old for it still gets the crop and, having no animation-timeline either, lands on the parked state anyway.

Audited before changing it, across all six pattern pages and the scroll-driven documentation pages: 40 scroll-driven animations, and the rim pass was the only one whose timeline source was anything but the document. Check the source, not just that the animation existsgetAnimations() reports a frozen timeline and a running one identically.

The isometric assembly is not the only thing on a view timeline. .text-foil swings the angle its gradient is drawn at, by one brand angle, as the type crosses the viewport — a sheet of foil looks different depending on the angle you hold it at, and the reader's scroll position is that angle. It is built the same way and switches off through the same three doors: @supports (animation-timeline: view()), (prefers-reduced-motion: no-preference) and screen and. Every path away from it lands on the designer's own 116.57° rake, so print and reduced motion get the drawn state rather than a frozen frame of an animation. → Colour > The rake swings

Scroll is only half of the angle you hold a sheet of foil at, and it is the half a stylesheet can reach. The other half — how far to the side of the reader the type is — is measured by assets/js/cf-sight.js and multiplied into the same rake, which is the one thing in this system driven by a pointer rather than by a timeline. It is optional, it rests at the designer's value, and it is the only motion here that never runs on a touch device at all. → The Line of Sight

The two frosted surfaces make the third and fourth. The navigation rim runs on scroll(root block) rather than a view timeline — the bar is a plane the whole document moves under, so the document is its clock — while the hero glass button's rim pass runs on view(), because that plate is one object the reader carries up the screen once. Both park their band off-canvas as a plain declaration, so all four doors lead to the drawn state rather than to a frozen frame. → Materials > Working with glass

The navigation rim is where the screen rule above was found to have been broken, which is worth recording because the rule was already written down and the violation still shipped. That block carried (prefers-reduced-motion: no-preference) without screen and, and the consequence was sharper than the illustrations' was: print has no scroll, so a both-filled animation holds its from keyframe — and cf-nav-edge's from is opacity: 0. Measured under print emulation, the rim computed to opacity 0, so every printed page lost the hairline separating the bar from the content beneath it. Scoped, print never applies the animation and the rest declaration draws the edge. A rule stated in prose is not a rule that is enforced; when you add the fourth door, check the other three on everything already through them.

A pinned track: build, hold, hand over

Three sections pin a stage and scrub a sequence through it — Unsere Werte, Expertise, and the landing page's Was wir machen — and between them they settled three rules that are not obvious and were each learned by getting them wrong first. They are here rather than on any one page because the next pinned track should not have to learn them again.

The landing track is the shape to copy when the copy is plain prose rather than typed: the same quarters, the same 62 % reveal, the same lectern move — one constant frame, four cards handing over on it — but the panel's lines fade in on staggered windows instead of streaming, so it needs no script at all. And it is the worked example of re-timing a .cf-iso--build onto a track: a sticky object never travels through the viewport, so its own view timeline freezes at one value — the parts keep their --stage, --build-dx / --build-dy and the cf-iso-build keyframe, and only the timeline and the windows are the page's.

The hold is the point

A stage divides into a reveal and a hold, and the split is at 62 %. Everything that moves — the object assembling, the copy typing, the light coming up — finishes inside the first 62 % of the stage, and the remaining 38 % is a still: nothing moving, the drawing complete, the sentence whole.

A stage whose reveal runs to its own edge cannot be read at all. Expertise shipped that way and it is the failure this section exists to record: the last node landed and the last character typed at the moment the step began to leave, so the reader was chasing the end of the sentence with the wheel and there was never a frame where the thing was simply finished and standing still. Range spent on a hold is not range wasted. It is the only part of the sequence anybody actually reads.

62 % is one number in two places: REVEAL in assets/js/cf-stream.js, and the point every CSS range on the Expertise stage ends at. The type stops typing exactly where the drawing stops drawing, because both are told the same fraction rather than tuned until they looked equal.

The copy is typed, not faded

assets/js/cf-stream.js types the copy character by character as the track scrubs, which is the one thing a view timeline cannot do — there is no per-character unit to animate. Scrolling back up un-types it, because it is driven by position and not by a timer. It is optional and additive in the sense this system keeps using: the copy is real markup, every stage is built by CSS, and without the script every line is simply present.

It is one engine with two consumers. It was cf-values.js, named for the only section that had it, until Expertise wanted the same behaviour — and a second copy of a scrubbing typewriter is two timings that will not stay equal. Any pinned track can ask for it now with three data attributes:

<div class="…-track" data-stream>
  <article data-stream-stage>
    <h3 data-stream-line="0 0.24">…</h3>      <!-- from, span: fractions of the reveal -->
    <p  data-stream-line="0.26 0.48">…</p>
  </article>
</div>
Reserve the height before you type into it. A line being typed is a line with no height yet, and four of them growing under each other walk the whole card down the page while the reader is trying to read it. Both sections declare min-height in em on every streamed line — measured on the settled copy — so the characters arrive into a box that is already the size it will end at. Measured on Expertise, the card holds 540 px from the first character to the last.

And the caret follows the line being typed, not the last line in the block. With two lines those are the same answer; with four it parks a blinking cursor on an empty paragraph two lines below the one that is moving.

Accessibility: the streamed span is aria-hidden and a .visually-hidden twin carries the whole sentence, so assistive technology always gets the full line no matter where the scroll is. Nothing is ever announced half-written. The script also refuses to run at all unless the pinned layout is active — no scroll-timeline support, reduced motion, or under 820 px and every stage is on screen at once, where typing one of them would be nonsense.

The two halves of a stage hand over differently

A stage with a drawing on one side and copy on the other cannot use one transition for both, and this is the least obvious of the three.

And it is the surface that travels, not what is written on it. The pane swipes in from off the right edge, lands empty, and only then does anything appear on it — a sheet of glass being put in place, and then written on. The first version had that the wrong way round: the plate stood still and the copy flew across it, which is a caption sliding over a window rather than a window arriving. So the copy does not translate at all. It fades in behind the typing once the pane is under it, and leaves with the pane.

The pane leaves left and returns right rather than retreating the way it came, so four stages read as four panes on a belt rather than as one pane hesitating. In and out on the same side was tried first and it put the arriving and the leaving states head-on in the same square of screen. The exit is short and mostly opacity, so the pane is gone before it is over the drawing.

One element, four swipes. A shipping page gets two blurred layers and the navigation band is the other, so the pane cannot be four panes taking turns — see Materials and scripts/check-glass-budget.py. It is one element with one animation over the whole track and the cycle written out four times; the jump back to the right happens across a tenth of a point at each quarter boundary, while the pane is at opacity 0. The last cycle has no exit, because the track ends with the pane still standing.
Every line has to be opaque before its own first character lands, and an even stagger cannot do that — the lines do not start typing at even intervals. Measured at 8 % of the Expertise track with a linear stagger: 60 characters of the body written, none of them on screen, while the chips beneath were already lit. Give each line a window that closes where its own stream opens.

Hero artwork

The landing page hero runs an isometric loop (assets/video/hero-abstract-art.mp4): slowly shifting planes in CF-Grau with lime edges. It ships as a 1440 × 1440 square — the media box covers with it, so the crop, not the file, decides what is on screen — 12 s at 30 fps, H.264 High, no audio track at all rather than a silent one, and faststart, so the first frames decode before the file has finished arriving. Rules for embedding it:

The still switch

The loop runs 12 seconds and repeats forever, behind the headline. That is the exact shape of content WCAG 2.2.2 Pause, Stop, Hide (level A) governs: moving, starting automatically, lasting more than five seconds, presented in parallel with other content. The criterion asks for a mechanism the reader can operate. prefers-reduced-motion is not one — it is a sufficient technique for 2.3.3 Animation from Interactions, a different criterion, and it appears nowhere in 2.2.2. The system had the preference and not the mechanism, so this was a level A failure on the one page that carries video.

The mechanism is a checkbox and two sibling selectors. No script. That is not minimalism for its own sake: a scripted control is absent for readers with scripting off, who are disproportionately the readers relying on the mechanism.

<header class="cf-hero">
  <!-- first, so the ~ selectors can reach the media box -->
  <input class="cf-hero__still-toggle" type="checkbox" id="cf-hero-still">
  <label class="cf-hero__still" for="cf-hero-still" data-theme="inverse"></label>
  <div class="cf-hero__media">
    <videoautoplay muted loop playsinline></video>
    <img src="…/hero-poster.jpg" alt="">
  </div>
</header>
.cf-hero__still-toggle:checked ~ .cf-hero__media video { display: none; }
.cf-hero__still-toggle:checked ~ .cf-hero__media img   { display: block; }

The switch is beside the media box, not inside it, and that is a fix rather than a preference. .cf-hero__media is a stacking context on purpose — it is what holds the artwork and the reading scrim under the type without the type having to state an order. It held the control down with them. A z-index written inside that box only ranks against the video and the scrim; against the headline the whole subtree is one layer, underneath. Nothing looked wrong, because the type has no background — but .cf-hero__body is width: fit-content on the headline's longest line, so the h1's box runs the full column on every line including the short ones, and wherever it reached the plate the h1 took the click. At 375 × 812 — the width where the hero runs short and the headline climbs to the plate's band — all three hit-test points on the plate returned h1.cf-hero__title. The one control 2.2.2 asks for was unreachable by pointer on a phone and worked fine from the keyboard, which is why it survived. Out beside the media box the plate ranks against the type directly, at 2 against 1; the geometry is identical, since both boxes resolve against the same padding box. → scripts/check-pointer-reach.py

Which of the criterion's three verbs this is, stated plainly: hide. display: none removes the video from the presentation; it does not pause the element, so the decode continues in the background. Hiding is what 2.2.2 asks for and it is the only one of the three CSS can reach on its own. The cost is real and worth naming rather than glossing: the switch buys stillness, not battery. If a future version accepts a script here, video.pause() buys both — but it must be an addition to this, never a replacement for it.

A second honest edge: the poster is the video's first frame, so stopping the loop mid-cycle cuts to that frame rather than freezing on the current one. It is a cut, not a layout shift — nothing moves and nothing reflows — and it is exactly what a reduced-motion reader has always seen.

ConditionLoopStillSwitch
defaultrunshiddenshown, bars
switch checkedhiddenshownshown, triangle
reduced motionhiddenshownremoved
no CSS / no JSrunshiddenshown and working

Row three is deliberate: under reduced motion nothing is moving, so a switch that cannot change what the reader sees would be worse than none. Two rules in that table caught real bugs and both are recorded at the CSS. The removal has to come after .cf-hero__still's own display: grid — both selectors weigh (0,1,0) and a media query adds no specificity, so above it the block lost silently. And the checkbox is not .visually-hidden: the hero is pulled up by the nav's height, so a 1 px clipped input at the media box's origin sits above the top of the document, and focusing it scrolled the page away from the plate the reader was aiming at. It occupies the plate's own box instead, transparent and not hit-testable.

The two marks are drawn, not filled, like everything else in the system — and both sit on sanctioned angles. The bars are 90°. The play triangle is 13 across by 13 tall about its apex, which puts both of its edges at dx 2 / dy 1 — 26.57°, the isometric angle, rather than the 30° a generic play glyph carries. It is a flatter triangle than the one most interfaces use, and that is the point: it is the same edge as the rhombus the whole icon set is built from.