Design language Foundations
Motion
Motion on our website explains; it never decides whether content is there. Every page arrives finished, with one exception in the home hero, and motion is layered on top only when your browser can run it and you have not asked for less. Our stylesheets name one curve, and scripted motion takes its durations and curves from one small vocabulary. Every loop can be paused, and it stops on its result.
Content first, motion on top.
#Every page is readable before any script runs: what the server sends is the final state of every section and every visual, with one exception described below. Motion is a layer that a script adds afterwards, and only where it is welcome.
Three states of a page.
Pages are served with the class no-js on the root element. The site script that runs on every page removes it, and adds motion only if you have not asked for reduced motion. A page therefore ends up in one of three states.
| Situation | Class on the root | What you see |
|---|---|---|
| Script off | no-js | The page as served: every section and visual in its final state. |
| Script on, reduced motion requested | neither | The same final state. Nothing moves and no pause control appears. |
| Script on, motion welcome | motion | Pre-states apply, then each element moves into the state the server already sent. |
Pre-states live under the motion class.
A pre-state is how an element looks before it moves: lowered, undrawn or hidden. Pre-states are written under html.motion, not on the element alone, so none of them applies unless the site script has run. The reveal rules in the global stylesheet show the pattern.
html.motion [data-r] {
transform: translateY(14px);
transition: transform 0.8s var(--ease-out-quart);
}
html.motion [data-r].in {
transform: none;
}
html.motion [data-r="rule"] {
transform: scaleX(0);
transform-origin: left;
}
html.motion [data-r="rule"].in {
transform: none;
}One exception: the home hero.
The home hero pins a scene in two parts. So that it never shows for a frame before the script takes over, its second text block starts clipped on the element itself, not under html.motion. It is shown in full when scripting is off or reduced motion is on, and the hero script shows it if its own set-up fails. If scripting is on but that script has not arrived within 6 s, a CSS rescue opens it and unpins the scene; a script that arrives later keeps that layout.
Each page checks its own final states. The table under Reduced motion lists which test covers which page.
Without a script, every visual is in its final state.
WhyA script that is switched off or blocked must still leave the whole argument of the page on screen.
TestEnforced by a test on every change.
Pre-states are written under the motion class; the home hero is the one exception.
WhyContent that is visible by default can only ever fail towards visible.
ReviewKept by review, not by a test.
One curve in our stylesheets.
#Our stylesheets define a single motion token: an easing curve, in the token file. It starts fast and spends most of its time settling, so an element arrives quickly and comes to rest gently.
- Token
--ease-out-quart- Value
cubic-bezier(0.23, 1, 0.32, 1)- Control points
P1 (0.23, 1) P2 (0.32, 1)
Where the time goes.
A quarter of the way through its duration, an element on this curve has covered 78 percent of its distance; at half time it has covered 97 percent. The rest of the time is the settle, and the settle is what makes an arrival feel calm.
| Time elapsed | On the curve | At constant speed |
|---|---|---|
| 0.1 | 0.398 | 0.1 |
| 0.25 | 0.775 | 0.25 |
| 0.5 | 0.966 | 0.5 |
| 0.75 | 0.997 | 0.75 |
The name and the value
The token is called --ease-out-quart. Read the four numbers, not the name: they define the curve, and they are exactly the ease-out curve of the Zero Door interface.
Download our token file (tokens.css)
A few transitions leave the curve out for a secondary colour change, which then falls back to the browser default. The guard counts a curve that is named, not one that is left out. It also skips a zero-length switch, such as the change of visibility at the end of a close: with no duration, there is no motion for a curve to shape. Any other curve that a transition or an animation names fails it, whether a keyword (ease, ease-in, ease-out, ease-in-out, linear, step-start and step-end) or a function (cubic-bezier(), steps() and linear()).
Every curve that a CSS transition or animation in our source names is the token.
WhyOne curve gives the whole site one way of arriving.
TestEnforced by a test on every change.
Scripted curves come from one module.
Timelines built with GSAP do not name their own curves. They take them from one module, the vocabulary of scripted motion; the stylesheet token covers CSS only. The curves below are read from that module at build time. Only EASE.pop overshoots, and only marks use it.
| Token | GSAP curve | Used for |
|---|---|---|
EASE.enter | power3.out | Arrivals: an element opens, rises or settles, fast at first. |
EASE.exit | power2.in | Exits: closing, erasing and pulling back, accelerating away. |
EASE.move | power2.inOut | Changing place and settling: flows, scan passes, colour changes. |
EASE.draw | sine.inOut | Drawing and gliding, slow and even: a line drawing itself, the camera, a jump between steps. |
EASE.pop | back.out(1.7) | Marks only: a dot, a stop, a check box or a seal settles with a slight overshoot. |
EASE.linear | none | Motion tied to scroll, and proxies that time drives. |
Only marks overshoot: a dot, a stop, a check box or a seal. A verdict, a number or a label opens without overshoot.
WhyA verdict or a number that shows more than it is before it settles says something other than what it is.
TestEnforced by a test on every change.
Durations: values in CSS, tokens in scripts.
#Our stylesheets define no duration tokens: each CSS duration is written where it is used, so the values below are collected from the source. Read them as values in use, not as a scale. Scripted motion works the other way round: every duration it uses comes from one small set of tokens, further down.
| Use | Duration |
|---|---|
| Button feedback | 0.16s |
| Pause control feedback | 0.16s |
| Colour change in illustrations (fill and stroke) | 0.3s in 16 of 17 rules |
| Width of the active step pill | 0.6s |
| Reveal | 0.8s |
The timings of the scroll scenes are rules rather than single values, so they are described with the scenes.
Scripted durations are tokens.
GSAP timelines take every duration from that vocabulary: 7 steps from 0.16 s to 1.8 s, each at least 1.3 times the one before it, so that no two steps look alike. REST is the rest a loop takes between passes, longer than any single move.
| Token | Seconds | Used for |
|---|---|---|
DUR.tap | 0.16 | Colour and small feedback |
DUR.exit | 0.24 | Exits: closing, erasing |
DUR.mark | 0.32 | Marks (a tick, a dot, a ring) and one row of a scan |
DUR.enter | 0.45 | Entries: opening, rising, a card arriving |
DUR.travel | 0.8 | Changing place, flowing, a colour settling |
DUR.draw | 1.2 | A line or a contour drawing itself |
DUR.sweep | 1.8 | A scan passing over the whole visual |
REST | 2.4 | The rest a loop takes between passes |
Named motions.
Most tweens spread one of 7 named motions, a duration and a curve under one name, and change the duration where they must: { ...MOTION.draw, duration: DUR.enter }.
| Motion | Duration | Curve | Used for |
|---|---|---|---|
MOTION.draw | DUR.draw | EASE.draw | A line, a contour or a band draws itself |
MOTION.mark | DUR.mark | EASE.enter | A short trace draws itself: a tick, a notch, a short link |
MOTION.erase | DUR.exit | EASE.exit | Closing, erasing, pulling back |
MOTION.scan | DUR.mark | EASE.move | A scan passes one row |
MOTION.settle | DUR.travel | EASE.move | A colour or a position settles: a flow, a change |
MOTION.pop | DUR.mark | EASE.pop | A mark settles into place: a dot, a stop, a check box, a seal. The one motion that overshoots |
MOTION.enter | DUR.enter | EASE.enter | An element that carries text opens: a card, a label, a verdict, a number. No overshoot |
In the two scroll scenes these durations are lengths on the master timeline, not seconds: scroll maps onto that timeline, so there the tokens set the proportions of a flow, not its clock time. Positions on a timeline, the gap of a stagger and the period of a loop are layout, not vocabulary: they stay as named constants in their component.
What the scripted guard catches.
A unit test reads every script of the site, the <script> blocks of components included, and fails on any of the following.
- a curve given to
easeas a string - a curve given to
parseEaseas a string - a GSAP curve name anywhere in a string
- a bare number given to
duration,delayorrepeatDelay - a bare number as the default value of a duration
- a bare number passed to
.duration(),.delay(),.repeatDelay()ordelayedCall()
It leaves 4 sources alone, each with its reason in the test. A second test fails as soon as one of them no longer needs its exemption, so the list cannot outlive its reasons. Stylesheets have a guard of their own, described with the curve above.
| What | Why |
|---|---|
| The motion vocabulary | The vocabulary itself: the durations and curves are defined here. |
| The home hero | Out of scope for now: the home hero is being rewritten in separate work and joins the vocabulary when that work lands. |
| The data residency visual | Out of scope for now: the data residency visual of the Trust centre is being rewritten in separate work. |
| Zero Door interface facts | Not motion: a fact about the Zero Door interface, its tooltip delay in milliseconds. |
Scripted motion takes every duration and curve from the vocabulary.
WhyWhen each component wrote its own numbers, one motion was described many ways and the site stopped moving as one.
TestEnforced by a test on every change.
Control feedback
#Buttons answer within 0.16 s on the same curve. The colour shifts on hover and, under a press, the button shrinks slightly to scale(0.97). Try the buttons below.
.btn, .btn-ghost and .btn-ink classes from the global stylesheet.| Element | Property | Duration | Curve |
|---|---|---|---|
.btn | transform | 0.16s | var(--ease-out-quart) |
.btn | background-color | 0.16s | var(--ease-out-quart) |
MotionButton | color | 0.16s | var(--ease-out-quart) |
MotionButton | border-color | 0.16s | var(--ease-out-quart) |
Under reduced motion the change is instant: the button still answers, without the travel.
Button feedback (
.btn) runs at 0.16 s on the token and presses toscale(0.97).WhyFeedback has to arrive before you wonder whether the press registered.
TestEnforced by a test on every change.
Reveal: a 14px rise and a drawn hairline.
#Most content on the site uses the simplest motion there is. An element marked data-r starts slightly low and rises into place. A hairline marked data-r="rule" draws itself from the left.
Rule
Change is a question, not an alert.
The heading and this line rise into place, and the hairline above them draws from the left.
data-r and data-r="rule" and use the real rules. Replay takes away the class that opens them and gives it back.| Part | Value |
|---|---|
| Pre-state | translateY(14px) |
| Hairline pre-state | scaleX(0), growing from transform-origin: left |
| Transition | transform 0.8s var(--ease-out-quart) |
| Opens at load | top above 0.92 of the viewport height |
| Opens on scroll | in view at threshold 0.12, once |
| Safety net | everything opens 3000 ms after the script runs |
Elements already in view are opened in the same synchronous block that adds the motion class, so they are never hidden for a frame. The rest open once, the first time they come into view, and are then forgotten. 3 seconds after the script runs, a safety net opens anything still waiting, so an observer that never fired cannot leave a section lowered.
There is no stagger between reveals. Each element moves on its own trigger.
A reveal moves
transformonly: a 14px rise or ascaleXhairline, over 0.8 s on the token.WhyA small, fast move of one property reads as arrival, not as a show.
TestEnforced by a test on every change.
Every reveal opens within 3 seconds, seen or not.
WhyContent must never wait for an observer that did not fire.
TestEnforced by a test on every change.
What may move.
#Motion moves transform, clip-path, stroke-dashoffset and SVG geometry such as r, width and height. Opacity is never animated, and layout moves only in a few named answers to your own input.
Opacity is never animated.
Partial opacity lowers contrast for as long as a transition runs. When reveals faded in, automated contrast checks measured text in the middle of its fade and failed it, and a reader with low vision meets the same faint frames. A rise keeps text at full ink from the first frame to the last, so no text or interface element fades in or out.
What the guard checks.
The test reads every component, script and stylesheet of the site. It fails on a transition that names opacity, names all or names no property at all, on a keyframe that changes opacity, on a script that writes opacity (a GSAP or Web Animations key, a style write or a transition string), on an SVG animate element for opacity, on utility classes whose motion includes opacity and on cross-fading page transitions. It ignores comments and static opacity. It also ignores the noise canvas, which draws each frame with its own alpha: there, alpha is a drawn value, not a transition.
No transition, keyframe, script or utility class in our source animates opacity.
WhyText must pass contrast in every frame, not only at rest.
TestEnforced by a test on every change.
Layout stays still.
Reveals and scroll motion do not animate width, height, top, left, margin or padding: a change of layout moves everything after it. Geometry that differs between motion and no motion is switched by media queries, not by a script class, so the motion class itself never changes layout.
The observed exceptions are all answers to your own input. The active step pill of a scroll scene widens over 0.6 s. In the rules section of the home page, on wide screens (min-width: 1200px), a rule's frame grows over 0.55 s on hover, focus or a tap, while its glyph slides with left over 0.55 s. On narrower screens (max-width: 1199.98px) a rule opens like an accordion when you tap it: its description grows with grid-template-rows over 0.45 s.
Content does not animate layout properties; the step pill and the opening of a home page rule are the named exceptions.
WhyAnimated layout shifts the page under your hand and moves everything after it.
ReviewKept by review, not by a test.
One lifecycle for every visual.
#Illustrations that play when they come into view share one function, revealMotion. It moves a visual through four states and writes the current one on the element as data-state, where tests and developer tools read it.
Reduced motion
staticfinalize()The final state is set at once. No intro, no loop.
Motion welcome
readyprepare()The visual is put into its pre-state and waits.
- in view (each visual sets its own threshold)
playingintro()A short intro plays once and ends on the result. Off screen it waits and later resumes; if you pause motion, it jumps to its end.
- intro complete
playedambient()An optional quiet loop begins from the result, and the element gets
data-ambient="on".
The loop runs only while the visual is on screen, the tab is visible and motion is not paused; paused, it parks on the result. The accessibility test looks for data-ambient="on" and fails the page if no pause control is visible.
revealMotion. The state names are read from the code; the highlighted state is the live state of the curve specimen further up this page.Live: the curve specimen on this page is in state none, no script has run
| Option | Required | What it does |
|---|---|---|
intro | yes | Builds the one-time intro, the first time the visual is in view. |
ambient | no | Builds the loop that starts when the intro completes. |
prepare | no | Sets the pre-state before the intro. |
finalize | yes | Sets the final state under reduced motion. |
threshold | no | How much of the visual must be in view before the intro starts. The default is 0.25; all 20 visuals on the site pass their own, from 0.2 to 0.5. |
rest | no | The moment in the loop that shows the result in full; a paused loop parks there. The default is 0, a loop that starts from its result, and all 18 loops on the site keep it. |
| Condition | Intro | Loop |
|---|---|---|
| The visual leaves the screen | Waits, then resumes | Waits |
| The tab goes to the background | Waits, then resumes | Waits |
| You press pause | Jumps to its end: the result | Parks on the result |
Results first.
An intro ends on the result: a verdict, marks, stamps, what was found. A loop begins from that result and runs the check again over the finished picture, answering a mark with a ring that draws itself and is rubbed out again, or passing a dot along a line; the result itself stays. A test samples 6 visuals for longer than a pass, and again after a pause: the dam illustration, the coverage grid, the attack path, the findings report, the lab session and the hex scan. Two loops move more than a mark, by design: the legitimacy comparison on the Zero Door page scans its rows again while its verdict stays, and the fleet search moves from one query to the next.
Every visual reaches its final state after its intro.
WhyA visual that stalls halfway tells half the story.
TestEnforced by a test on every change.
A loop never wipes the verdict to play again.
WhyThe intro ends on the point. A loop that takes it away makes you wait for it, and a pause could stop on an empty frame.
TestEnforced by a test on every change.
Paused, a visual stops on its result.
WhyWhoever pauses still sees the whole conclusion, never half of it.
TestEnforced by a test on every change.
Quiet loops
A loop is quiet: it starts from the result, runs its check again over the finished picture and rests between passes. The values below are read from the vocabulary and the components.
| What | Value |
|---|---|
| Rest between passes | REST, 2.4 s, in 16 of the 18 visual loops |
| Pulse along the day dots, results scene | scale up to 1.1 |
| Length of the results scene loop | 60 s, a whole multiple of every period inside it: 3 and 4 seconds |
| Curve specimen on this page | DUR.sweep, 1.8 s, for each crossing, with REST between crossings |
The other 2 visual loops do not rest:
- The data residency visual of the Trust centre, which keeps its own timing while the scripted guard leaves it alone.
- The framework fan: a dot travels each line in turn, with no pass to rest after.
Make a loop seamless.
Start and end the loop on the result, the state the intro left behind. Pad its tail with an empty set() placed REST after its last move, or give the timeline repeatDelay: REST, so that the rest is part of the loop. If the loop runs on periods, make it as long as a whole multiple of every period inside it. Then nothing jumps when it wraps around.
A loop that replays a pass rests
RESTbefore the next one.WhyThe rest is where the result is read; a check that never pauses keeps asking for attention.
ReviewKept by review, not by a test.
A loop stops when its visual leaves the screen.
WhyMotion that nobody can see costs battery and attention for nothing.
TestEnforced by a test on every change.
A loop ends in the state it started from: the result.
WhyNothing should jump when a loop wraps around.
ReviewKept by review, not by a test.
Scroll-linked scenes
#Two sections tell a longer story in steps: the results section on the home page and the decision scene on the Zero Door page. Each pins to the screen while you scroll, and your scroll position is the playhead. No step advances on its own: every change starts with your scroll or a step button.
Each scene has one master timeline: an intro, then a hold for every step and a flow between each pair of holds. Scroll position maps onto this timeline piece by piece.
- Hold: the step stands and a quiet loop can run.
- Flow: one step becomes the next, driven by your scroll.
Scrub
Each scroll update eases the timeline towards the time that matches your position. The tween lasts 0.08 seconds for every second of timeline it has to cover, held between 0.5 s and 1.2 s, and it decelerates on EASE.enter. Scroll back and the same flow plays in reverse.
Finish on stop
Stop in the middle of a flow and, after 180 ms without a scroll event, the page itself scrolls on to the end of that flow in the direction you were going, or back to its start. It takes DUR.enter, 0.45 s, plus up to DUR.draw, 1.2 s, in proportion to how much of the flow is left, on EASE.draw. Your own input cancels it at once: wheel, keydown and touchstart. It does not start while a finger or a pointer is down, nor after you scroll with the keyboard: an arrow or page key chooses where you stop.
Ambient while holding
While a step holds, a quiet loop can keep it alive: a dot runs along a curve, a pulse runs along the days. A step that shows a fingerprint or a verdict holds still: a value that moves while you read it is not yet settled. Ambient motion stops during a flow, off screen, in a background tab and when you pause.
Step buttons
The step dots are real buttons, and the arrow keys move between them. A jump plays the flow at a rate you can follow: 0.3 seconds for every second of timeline, held between 0.9 s and 2.4 s, on EASE.draw.
| Constant | Value | Meaning |
|---|---|---|
SMOOTH | 0.5 s | Shortest smoothing of a scroll update |
SMOOTH_MAX | 1.2 s | Longest smoothing of a scroll update |
JUMP_RATE | 0.3 | Jump time for every second of timeline to cover |
JUMP_MIN | 0.9 s | Shortest jump from a step button |
JUMP_MAX | 2.4 s | Longest jump from a step button |
IDLE | 180 ms | Quiet time before a stopped flow is finished |
See them in place.
This page does not rebuild a scene. Open one, scroll slowly, then stop in the middle of a change.
Scroll drives the scene, and a stop in mid-flow completes in the last direction.
WhyMotion is tied to your hand, but never left frozen halfway.
TestEnforced by a test on every change.
Stopping on the way up returns to the previous step.
WhyThe finish follows your direction, not a fixed one.
TestEnforced by a test on every change.
No step is skipped, in either direction.
WhyScroll windows touch but never overlap.
TestEnforced by a test on every change.
A step button pressed during the automatic finish wins over it.
WhyThe page never overrides what you just asked for.
TestEnforced by a test on every change.
After keyboard scrolling, the page never finishes a flow by itself.
WhyAn arrow or page key chooses where you stop; a page that then moves on its own takes that choice away.
TestEnforced by a test on every change.
A step that shows a verdict holds still.
WhyMotion on a decided step reads as doubt.
TestEnforced by a test on every change.
One pause for the whole site.
#Anything that moves on its own for more than a moment has a small pause control beside it. Every control on every page drives one shared state, so one press stops every loop on its result, and your choice follows you to the next page.
Live
- State
- Class on the root
- no script has run
- Stored value
- no script has run
| Part | Value |
|---|---|
| Target size | 28px by 28px |
| Border | 1px solid var(--color-hair-2) |
| Icon | two bars or a triangle drawn in CSS, in var(--color-ink-muted) |
| On hover | icon var(--color-ink), border var(--color-ink-subtle), over 0.16 s |
| Keyboard focus | outline: 2px solid var(--color-accent), offset 2px |
| Accessible names | Pause animations / Play animations |
| Stored as | localStorage key zs-motion set to 'paused' |
| Shown as | class paused on the root |
| Hidden when | there is no script, or reduced motion is on: there is nothing to stop |
| Function | What it does |
|---|---|
isPlaying() | Whether motion is allowed right now. |
setPlaying(next) | Changes the state, stores it and tells every listener once. Setting the same value again does nothing. |
onMotion(fn) | Calls the listener at once with the current state, then on every change. Returns a function that ends the subscription. |
Pausing stops time-based motion only. In a scroll-linked scene your scroll still moves the scene while motion is paused, because that motion is yours. The finish after a stop also runs while paused: it is short, and it only follows a scroll you started.
Where the browser blocks storage, the choice applies to the current page only.
Placement
In use, the control sits under its visual, aligned to the right. Over the full-bleed canvas of the home hero it sits in the bottom right corner, and in the top right corner on narrow screens (max-width: 900px). In a scroll scene it sits in the step row. This placement is observed, not a written rule.
A page on which a loop starts shows a visible pause control.
WhyWCAG 2.2.2 asks for a way to pause any motion that starts on its own and lasts more than five seconds.
TestEnforced by a test on every change.
One press pauses every loop on the page, and the choice survives a reload.
WhyOne decision, respected everywhere.
TestEnforced by a test on every change.
The choice holds across pages.
WhyYou should not have to ask twice.
TestEnforced by a test on every change.
The storage key does not change.
WhyA renamed key would quietly forget the choice of every visitor who has paused.
TestEnforced by a test on every change.
Motion that plays once and ends within five seconds needs no control.
WhyA reveal is over in 0.8 s, well inside that limit.
ReviewKept by review, not by a test.
Loops that revealMotion does not run mark themselves the same way. The noise canvas and the scroll cue in the home hero and the holds of the two scroll scenes set data-ambient="on" when they start, so the accessibility test counts them too. The cross-page pause test and the results and Zero Door tests check their controls in more detail.
Reduced motion and no script
#If you ask your system for reduced motion, or your browser runs no scripts, you get the same page in its final state. Nothing is skipped: the final state is the content.
| Layer | Reduced motion | No script |
|---|---|---|
| CSS transitions | Every duration collapses to 0.01ms | Unchanged: hover and press feedback still runs |
| CSS animations | animation: none | The scroll cue, the only keyframe animation in our stylesheets that moves, is hidden |
| Smooth scrolling | scroll-behavior: auto | Unchanged |
| Reveal | No motion class, so nothing is lowered | No motion class, so nothing is lowered |
| Visuals | finalize(), data-state="static" | The final state the server rendered |
| Scroll scenes | No timeline: a step change places the scene at once | Unpinned, with every step card listed |
| Pause control | Hidden | Hidden |
Where each page is checked.
No single test covers every page. Each page with motion checks its own final states, in its own tests.
| Page | Plays and ends | Reduced motion | No script |
|---|---|---|---|
| Zero Door | yes | yes | yes |
| Home: hero | does not apply | yes | yes |
| Home: results | does not apply | yes | yes |
| Home: reveals | does not apply | yes | no test |
| Services | yes | yes | yes |
| Trust centre | yes | yes | yes |
| Research | yes | yes | yes |
| This page | yes | yes | yes |
Known gaps
- Scripts read the preference once, when the page loads. If you change the setting during a visit, CSS follows at once and scripts follow on the next page.
- Visual pre-states have no safety net. If the site script runs but the script of a visual fails, that visual can stay in its pre-state. Only reveals, after 3 seconds, and the home hero, after 6 seconds, open on their own.
Under reduced motion the page never receives the motion class, so no reveal pre-state applies.
WhyThe reveal never starts, so it never has to stop.
TestEnforced by a test on every change.
Under reduced motion, visuals rest in their final states with no control.
WhyA still frame that tells the whole story takes the place of the motion.
TestEnforced by a test on every change.
A scene jumps straight to a step, with no duration and no idle motion.
WhyScroll still chooses the step; nothing travels.
TestEnforced by a test on every change.
Without a script, a scene stands still and lists its 4 steps.
WhyThe argument of the section survives without the stage.
TestEnforced by a test on every change.
The home hero is not pinned under reduced motion.
WhyBoth of its text blocks can be read without scrolling through a scene.
TestEnforced by a test on every change.
This page follows the same rules.
WhyIts specimens rest in their final states and show no control.
TestEnforced by a test on every change.
Engineering notes
#These notes come from comments in the code; most record something that broke. They apply to anyone animating SVG on the site with GSAP.
Draw strokes with autoRound off.
GSAP rounds pixel values to whole numbers by default. On a path with
pathLength="1"the dash offset runs from 1 to 0, so rounding turns the draw into a single jump: nothing, and then the whole line at once, halfway through the draw. Every dash-offset tween passesautoRound: false.DoautoRound: false. The stroke passes through every value between 1 and 0. Do notDefault rounding. Nothing, then the whole line at once. A curve draws through its intermediate values; it never appears in one step.
WhyA line that pops in halfway reads as a glitch, not as a draw.
TestEnforced by a test on every change.
Undraw a line past its end.
On a path with
pathLength="1", an offset of exactly 1 can leave a hair of stroke at one end, because the browser normalises the length only approximately. Going past 1 does not help while the gap is as long as the dash: the next dash then reaches the other end. Undrawn lines therefore usestroke-dasharray: 1 2and an offset ofUNDRAWN,1.02, and both ends stay clean.A mark falls when its line reaches it.
A curve does not advance evenly, so the moment a drawing line reaches a point is not that point's share of the length. The moment is found by inverting the curve (
invertEase), and the mark falls on it, neither early nor late.Hide SVG pre-states with visibility.
On SVG, GSAP writes
transformas an attribute, and a CSStransformrule always beats an attribute, so a CSSscale(0)pre-state wins for ever. Atransform="scale(0)"in the template fails too: GSAP caches that matrix and leaves an offset behind when the scale returns to 1. Hide the group withvisibilityinstead, and let the script unhide it and take over the scale. HTML elements, which GSAP styles inline, can use a CSStransformpre-state.CSSAn SVG pre-state that GSAP can take over /* Do: hide the group; the script unhides it and takes over the scale. */ html.motion .label { visibility: hidden; } /* Do not: GSAP writes transform as an attribute, and this rule always beats it. */ html.motion .label { transform: scale(0); }Scrubbed timelines use only to() and set() after zero.
A timeline that scroll plays forwards and backwards is built from
to()tweens and fromset()calls placed after time zero.from(),fromTo()and aset()at zero write their values while the timeline is being built, which breaks the way back. Ato()reads its start value when it first plays, which is always where the previous part ended.TypeScriptA timeline that scroll plays both ways // Only to() and set() after time zero. tl.to(dots, { ...MOTION.erase, attr: { r: 0 } }, at); tl.set(card, { visibility: 'hidden' }, at + EXIT); // Not from(), fromTo() or set() at zero: // they write while the timeline is built.Loops pass immediateRender: false.
In a repeating loop, a
fromTo()placed later in the timeline would write its start values at once, while the loop is being built, and its element would jump there before its turn. The loops on the site passimmediateRender: false, so each element stays where it is until its tween plays.Measure again on every pass.
A value measured once goes stale when the window changes size. A loop that depends on layout passes a function instead of a number and sets
repeatRefresh: true, so each pass measures again. ItsfromTo()tweens state both ends: with a refresh, a start left implicit would be read from wherever the last pass ended.A scan line runs between rows.
A scan pass (
scanPass) draws a line along the boundary between two rows, from the left, and its tail follows it out. It never travels down across a row, so it never crosses text.Ambient motion writes only the transform attribute.
The quiet loops of the results scene write scale and offset straight into the SVG
transformattribute, never intox,y,widthorr. The layout values stay intact, a flow reads them undisturbed, and stopping the loop only removes the attribute.Read colour tokens once.
GSAP needs a real colour to interpolate and cannot tween
var(--color-ink). Scripts read each token once from the computed style, so raw hex still never appears in the source.Markers follow the curve equation.
getPointAtLengthbehaves differently across browsers whenpathLengthis set, so a marker that rides a curve takes its position from the cubic equation of that curve: in the results and decision scenes and on the framework fan. The curve specimen on this page works the same way.Hide with visibility when height matters.
Step cards that swap are hidden with
visibility, notdisplay, so the column keeps its height. On mobile a height change moved the scroll spacers and made a scene swing between two steps.
Not defined yet
#The code does not define motion for the following. Until it does, there is no rule to follow, and this page does not invent one.
- Page transitions. Pages load without one, and nothing defines motion for a change of page.
- Loading and skeleton states.
- Toasts and modal dialogs.
- Hover motion for links and cards. Today the frames of the home page rules move on hover. So does an arrow, by 3px, in the research cards on the services page, the link rows on the company page and the rows of the page list on the design language home page. On the research index, a card's cover image grows slightly. Buttons and the pause control change colour on hover but do not move. No rule says when a link or card may move.
- Stagger between reveals. Inside the intro of a single visual, small staggers are in use.
- A longest pass or a longest intro. The rest between passes is a token, but how long a pass or an intro may run is not, apart from the five-second limit for motion that needs no control.
Motion in Zero Door
#The Zero Door interface speaks in the calm register: short, functional motion in a tool people use all day. Its tokens come from the product's own token file, as of 2026-08-23, and the notes on use describe the product code at the same version. The press scale and the reduced-motion values are read from the product source, as of 2026-10-09.
Download the product tokens and the values read from its source (product-tokens.json)
--ease-outcubic-bezier(0.23, 1, 0.32, 1)In use: almost every transition in the product.--ease-in-outcubic-bezier(0.77, 0, 0.175, 1)Defined; no component uses it.--ease-drawercubic-bezier(0.32, 0.72, 0, 1)Defined; no component uses it.
| Token | Value | In use |
|---|---|---|
--duration-feedback | 130ms | Almost every transition |
--duration-fast | 160ms | A few components |
--duration-default | 200ms | Not used |
--duration-slow | 300ms | One score gauge |
--duration-drawer | 400ms | Not used |
The product defines 5 duration tokens and 3 curves. Our stylesheets define one curve and no durations; our scripts take 7 durations and 6 curves from one module. Only ease-out is in use in the product today, and it is the same four numbers as the website token. A few entry animations defined in the product styles use a curve of their own.
No accelerating curve.
The product defines no accelerating curve. On the website, scripted exits do accelerate: EASE.exit (power2.in).
Press
Press feedback in the product moves transform, background colour and opacity over 130 ms on ease-out. The press itself scales a button to scale(0.97), as on the website. Opacity is allowed there: the no-opacity rule on this page is a website rule.
Command palette
The command palette, built to be opened many times a day with Cmd or Ctrl and K, opens and closes without animation. An action repeated that often should never make anyone wait for it.
Reduced motion
Under reduced motion the product sets transition-duration: 0.01ms, animation-duration: 0.01ms and animation-iteration-count: 1, so every transition and animation is instant and no animation repeats. It also sets scroll-behavior: auto. The website shortens transitions the same way but removes animations altogether, with animation: none.