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.
| Token | Duration | Used for | In the CSS today |
|---|---|---|---|
--duration-fast | 120 ms | hover, focus, colour change, damping a measured value | 15 rules |
--duration-base | 240 ms | the control itself responding — arrow, button, menu | 11 rules |
--duration-slow | 480 ms | a reveal, or light crossing a surface | 8 rules |
--duration-scene | 1200 ms | a full object build, if one is ever driven by time | none 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.
--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
| Token | Value | Used for |
|---|---|---|
--ease-standard | cubic-bezier(.2, 0, 0, 1) | the default — quick start, soft arrival |
--ease-out | cubic-bezier(0, 0, .2, 1) | objects that fly in and stay — the whole isometric assembly |
--ease-in-out | cubic-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-visiblewhatever:hovergets. Every state pair incomponents.cssnames 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.pyenforces 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.
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.
.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.
| Figure | Contour rules | Assembles |
|---|---|---|
| landing page · the four process objects | yes | yes — 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 figure | yes | yes — axis, then rays, then the lime centre |
| 404 page · the level row with its socket lit | yes | yes |
| blog article · the plot | yes | yes — and it is the one that is not aria-hidden, so it carries role="img" and a <title> |
| Über uns · page-header figure | yes | no — above the fold |
| Über uns · value-row marks (×3) | yes | yes — scene only; they carry no ghost, light, trace or node |
| Part | What it is | What it does | Range |
|---|---|---|---|
.cf-iso__scene | the whole object | travels in on 26.57° — see below — and fades up. The only thing that moves. Silent under .cf-iso--build. | cover 5–30 % |
.cf-iso__form | solid contoured body | nothing 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__ghost | dashed reference geometry | fades up after the body it belongs to | cover 20–40 % |
.cf-iso__orbit | a ghost that circulates | fades up with the other ghosts, and keeps turning after them — see below | cover 20–40 % fading, 20–50 % turning |
.cf-iso__trace | the signal reaching the object — one stroke | draws itself, edge of the crop to edge of the crop — see below | cover 18–45 %, or a window inside it |
.cf-iso__light | the single lime-gradient element | its fill comes up last. Light arrives; it does not fly in. | cover 30–48 % |
.cf-iso__node | construction points | fade up last of all | cover 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.
.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-out —
cubic-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.
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>
| Part | Under .cf-iso--build | Range |
|---|---|---|
.cf-iso__scene | does 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__ghost | first 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 backwards | cover 2–14 % |
.cf-iso__orbit | excluded from that retiming — an orbit is a ghost that turns, and its turn is written against 20–50 % | unchanged |
.cf-iso__form | arrives on its stage, from --build-dx / --build-dy | cover 5–22 %, +7 % per stage |
.cf-iso__light | a 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__node | not staged. Construction points are added to a finished object, at its finished position, which is what they mean | unchanged |
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-built | Built: animated vs. still | Whole-scene arrival: animated vs. still |
|---|---|---|---|
| 01 Discovery | 0 of 495,616 | 24,925 | 25,379 |
| 02 Datenfundament | 0 of 495,616 | 34,982 | 36,058 |
| 03 Weniger Ausfälle | 0 of 495,616 | 25,826 | 34,896 |
| 04 Mehr Leistung | 0 of 495,616 | 25,270 | 25,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-dashoffset | renders as |
|---|---|
| 0 px | identical |
| 5 px | |
| 2.5 px | identical |
| 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:
| State | Pixels 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.
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 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
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 */
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.
| Engine | Status |
|---|---|
| Chrome / Edge 115+ | shipped, July 2023 |
| Safari 26+ | shipped, September 2025 |
| Firefox | as 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:
| Trace | Inside the crop | Was | Now |
|---|---|---|---|
| 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, incoming | 100 % | 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>:
| Stroke | User units | Fraction of the path |
|---|---|---|
| shaft | 112.50 | 0.3265 |
| chevron at x 228 | 77.37 | 0.2245 |
| chevron at x 193 | 77.37 | 0.2245 |
| upper barb | 38.68 | 0.1123 |
| lower barb | 38.68 | 0.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-dashoffset | Should be drawn | With non-scaling-stroke | Stroked in user units |
|---|---|---|---|
| 1 | 0 % | 0 % | 0 % |
| 0.75 | 25 % | 45 % | 25 % |
| 0.5 | 50 % | 91 % | 50 % |
| 0.25 | 75 % | 100 % | 75 % |
| 0 | 100 % | 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:
| Frame | Render scale | Was | Now |
|---|---|---|---|
.cf-process__figure — the four cards | 0.55 | 1.10 px | 1.00 px |
.ex-step__figure — Expertise | 0.78 – 0.86 | 1.55 – 1.71 px | 0.95 – 1.00 px |
.ill-single — the specimen on this system's illustration page | 0.65 | 1.30 px | 1.00 px |
.ill-frame — the reference strip | 0.19 – 0.25 | 0.38 – 0.50 px | 0.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.
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.
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:
| Front | Drawing | Weight | Lead | The 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:
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.
| Condition | What switches it off |
|---|---|
| No scroll-driven animation support | @supports (animation-timeline: view()) |
| Reduced motion | (prefers-reduced-motion: no-preference) |
| Print and any paged medium | screen 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
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 exists —
getAnimations() 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.
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>
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.
- The drawing crossfades. Two line objects dissolving through each other is legible, and the overlap is what stops the stage going blank at a boundary. Expertise gives it three points of range at each end.
- The copy does not. Two blocks of text at half opacity in the same column read as one title printed twice — at every ratio, and at every travel distance, because the column is wider than anything you can move it by. Measured at the handover with a four-point overlap: Maschinenbau leaving and Großanlagen arriving, both half-lit and 48 px apart. So the copy owns exactly its own quarter, snaps out and in across about a point of scroll each way, and the object's dissolve is what the eye holds while it does.
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.
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.
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:
muted,loop,playsinline,autoplay— never with sound.- A
posterwith the first frame, so the hero is there immediately — cut from frame 0 and saved 4:4:4. Three audiences see the still and not the loop (first paint, reduced motion, the pause switch), and at 4:2:0 the file that shipped sat 38.26 dB from the frame it stands for, which is further from it than the next frame of the loop is. →scripts/check-hero-poster.py - An
<img>of the same frame in the markup, becauseposterdisappears with the video. - Under reduced motion, only the still is shown.
- A switch on the page that stops it — see below.
- The headline sits on top of the video and needs at least 4.5:1 there — if in doubt, darken the lower half.
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"> <video … autoplay 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.
| Condition | Loop | Still | Switch |
|---|---|---|---|
| default | runs | hidden | shown, bars |
| switch checked | hidden | shown | shown, triangle |
| reduced motion | hidden | shown | removed |
| no CSS / no JS | runs | hidden | shown 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.