Design languageMotion

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.

The root element in each case
SituationClass on the rootWhat you see
Script offno-jsThe page as served: every section and visual in its final state.
Script on, reduced motion requestedneitherThe same final state. Nothing moves and no pause control appears.
Script on, motion welcomemotionPre-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.

CSSThe reveal rules, read from the stylesheet at build time
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.

P1P2011timeprogress
Token
--ease-out-quart
Value
cubic-bezier(0.23, 1, 0.32, 1)
Control points
P1 (0.23, 1) P2 (0.32, 1)
The token, plotted from its four numbers. The dot rides the curve, and the blocks cross the same track, one at constant speed and one on the curve. Each crossing takes 1.8 s, slowed so that the shape can be seen, and the loop rests between crossings.

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.

Distance covered over time, computed from the token
Time elapsedOn the curveAt constant speed
0.10.3980.1
0.250.7750.25
0.50.9660.5
0.750.9970.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.

Curves of scripted motion
TokenGSAP curveUsed for
EASE.enterpower3.outArrivals: an element opens, rises or settles, fast at first.
EASE.exitpower2.inExits: closing, erasing and pulling back, accelerating away.
EASE.movepower2.inOutChanging place and settling: flows, scan passes, colour changes.
EASE.drawsine.inOutDrawing and gliding, slow and even: a line drawing itself, the camera, a jump between steps.
EASE.popback.out(1.7)Marks only: a dot, a stop, a check box or a seal settles with a slight overshoot.
EASE.linearnoneMotion 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.

CSS durations in use
UseDuration
Button feedback0.16s
Pause control feedback0.16s
Colour change in illustrations (fill and stroke)0.3s in 16 of 17 rules
Width of the active step pill0.6s
Reveal0.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.

Duration tokens of scripted motion
TokenSecondsUsed for
DUR.tap0.16Colour and small feedback
DUR.exit0.24Exits: closing, erasing
DUR.mark0.32Marks (a tick, a dot, a ring) and one row of a scan
DUR.enter0.45Entries: opening, rising, a card arriving
DUR.travel0.8Changing place, flowing, a colour settling
DUR.draw1.2A line or a contour drawing itself
DUR.sweep1.8A scan passing over the whole visual
REST2.4The 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 }.

Named motions
MotionDurationCurveUsed for
MOTION.drawDUR.drawEASE.drawA line, a contour or a band draws itself
MOTION.markDUR.markEASE.enterA short trace draws itself: a tick, a notch, a short link
MOTION.eraseDUR.exitEASE.exitClosing, erasing, pulling back
MOTION.scanDUR.markEASE.moveA scan passes one row
MOTION.settleDUR.travelEASE.moveA colour or a position settles: a flow, a change
MOTION.popDUR.markEASE.popA mark settles into place: a dot, a stop, a check box, a seal. The one motion that overshoots
MOTION.enterDUR.enterEASE.enterAn 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 ease as a string
  • a curve given to parseEase as a string
  • a GSAP curve name anywhere in a string
  • a bare number given to duration, delay or repeatDelay
  • a bare number as the default value of a duration
  • a bare number passed to .duration(), .delay(), .repeatDelay() or delayedCall()

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 the scripted guard leaves alone
WhatWhy
The motion vocabularyThe vocabulary itself: the durations and curves are defined here.
The home heroOut of scope for now: the home hero is being rewritten in separate work and joins the vocabulary when that work lands.
The data residency visualOut of scope for now: the data residency visual of the Trust centre is being rewritten in separate work.
Zero Door interface factsNot 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.

Live: the real .btn, .btn-ghost and .btn-ink classes from the global stylesheet.
Feedback values, read from the source
ElementPropertyDurationCurve
.btntransform0.16svar(--ease-out-quart)
.btnbackground-color0.16svar(--ease-out-quart)
MotionButtoncolor0.16svar(--ease-out-quart)
MotionButtonborder-color0.16svar(--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 to scale(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.

Live: these elements carry data-r and data-r="rule" and use the real rules. Replay takes away the class that opens them and gives it back.
Reveal values, read from the source
PartValue
Pre-statetranslateY(14px)
Hairline pre-statescaleX(0), growing from transform-origin: left
Transitiontransform 0.8s var(--ease-out-quart)
Opens at loadtop above 0.92 of the viewport height
Opens on scrollin view at threshold 0.12, once
Safety neteverything 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 transform only: a 14px rise or a scaleX hairline, 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.

DoEnter with transform. In every frame of a 0.8 s reveal the text is at full ink.
Do notDo not fade with opacity. The middle frames fail contrast for as long as they last.

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

  1. readyprepare()

    The visual is put into its pre-state and waits.

  2. 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.

  3. 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.

The lifecycle of 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

Options
OptionRequiredWhat it does
introyesBuilds the one-time intro, the first time the visual is in view.
ambientnoBuilds the loop that starts when the intro completes.
preparenoSets the pre-state before the intro.
finalizeyesSets the final state under reduced motion.
thresholdnoHow 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.
restnoThe 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.
What stops the intro and the loop
ConditionIntroLoop
The visual leaves the screenWaits, then resumesWaits
The tab goes to the backgroundWaits, then resumesWaits
You press pauseJumps to its end: the resultParks 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.

Quiet loops in use
WhatValue
Rest between passesREST, 2.4 s, in 16 of the 18 visual loops
Pulse along the day dots, results scenescale up to 1.1
Length of the results scene loop60 s, a whole multiple of every period inside it: 3 and 4 seconds
Curve specimen on this pageDUR.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 REST before 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.

  1. intro
  2. hold 1
  3. flow
  4. hold 2
  5. flow
  6. hold 3
  7. flow
  8. hold 4
  • Hold: the step stands and a quiet loop can run.
  • Flow: one step becomes the next, driven by your scroll.
The master timeline of a scene with 4 steps, as the shared scene code builds it. Not to scale.

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.

Scene constants, read from the source
ConstantValueMeaning
SMOOTH0.5 sShortest smoothing of a scroll update
SMOOTH_MAX1.2 sLongest smoothing of a scroll update
JUMP_RATE0.3Jump time for every second of timeline to cover
JUMP_MIN0.9 sShortest jump from a step button
JUMP_MAX2.4 sLongest jump from a step button
IDLE180 msQuiet 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
no script has run
Class on the root
no script has run
Stored value
no script has run
The drawing shows the control in every mode, painted from its own stylesheet rules. The live control beside it is the real one, wired to the site-wide state: pausing it also pauses the curve above and every loop on the next page you open.
The pause control
PartValue
Target size28px by 28px
Border1px solid var(--color-hair-2)
Icontwo bars or a triangle drawn in CSS, in var(--color-ink-muted)
On hovericon var(--color-ink), border var(--color-ink-subtle), over 0.16 s
Keyboard focusoutline: 2px solid var(--color-accent), offset 2px
Accessible namesPause animations / Play animations
Stored aslocalStorage key zs-motion set to 'paused'
Shown asclass paused on the root
Hidden whenthere is no script, or reduced motion is on: there is nothing to stop
The shared state
FunctionWhat 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.

What changes
LayerReduced motionNo script
CSS transitionsEvery duration collapses to 0.01msUnchanged: hover and press feedback still runs
CSS animationsanimation: noneThe scroll cue, the only keyframe animation in our stylesheets that moves, is hidden
Smooth scrollingscroll-behavior: autoUnchanged
RevealNo motion class, so nothing is loweredNo motion class, so nothing is lowered
Visualsfinalize(), data-state="static"The final state the server rendered
Scroll scenesNo timeline: a step change places the scene at onceUnpinned, with every step card listed
Pause controlHiddenHidden

Where each page is checked.

No single test covers every page. Each page with motion checks its own final states, in its own tests.

Final-state tests by page
PagePlays and endsReduced motionNo script
Zero Dooryesyesyes
Home: herodoes not applyyesyes
Home: resultsdoes not applyyesyes
Home: revealsdoes not applyyesno test
Servicesyesyesyes
Trust centreyesyesyes
Researchyesyesyes
This pageyesyesyes

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.

  1. 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 passes autoRound: 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.

  2. 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 use stroke-dasharray: 1 2 and an offset of UNDRAWN, 1.02, and both ends stay clean.

  3. 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.

  4. Hide SVG pre-states with visibility.

    On SVG, GSAP writes transform as an attribute, and a CSS transform rule always beats an attribute, so a CSS scale(0) pre-state wins for ever. A transform="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 with visibility instead, and let the script unhide it and take over the scale. HTML elements, which GSAP styles inline, can use a CSS transform pre-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); }
  5. Scrubbed timelines use only to() and set() after zero.

    A timeline that scroll plays forwards and backwards is built from to() tweens and from set() calls placed after time zero. from(), fromTo() and a set() at zero write their values while the timeline is being built, which breaks the way back. A to() 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.
  6. 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 pass immediateRender: false, so each element stays where it is until its tween plays.

  7. 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. Its fromTo() tweens state both ends: with a refresh, a start left implicit would be read from wherever the last pass ended.

  8. 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.

  9. Ambient motion writes only the transform attribute.

    The quiet loops of the results scene write scale and offset straight into the SVG transform attribute, never into x, y, width or r. The layout values stay intact, a flow reads them undisturbed, and stopping the loop only removes the attribute.

  10. 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.

  11. Markers follow the curve equation.

    getPointAtLength behaves differently across browsers when pathLength is 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.

  12. Hide with visibility when height matters.

    Step cards that swap are hidden with visibility, not display, 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.
Curve tokens in the product
Duration tokens in the product
TokenValueIn use
--duration-feedback130msAlmost every transition
--duration-fast160msA few components
--duration-default200msNot used
--duration-slow300msOne score gauge
--duration-drawer400msNot 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.