Shots in this scene
Motion Tokens
In one line
A motion token is a named value (a duration, an easing or a spring) that components use instead of typing the number. Change it once and every component follows; name it by purpose (enter, exit, emphasis) and nobody has to remember numbers.
Think of it like this
A restaurant chain gives every branch the same recipe card: "sear 90 seconds". Cooks read the card; they do not guess. When head office finds 75 seconds is better, one card changes and every kitchen follows. Motion tokens are the recipe card for a site's movement.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- motion token
- A named motion value (duration, easing or spring numbers) kept in one place so components share it.
- primitive and purpose token
- A primitive says how big (
--dur-baseis 220 ms). A purpose token says what it is for (--motion-enter-duration) and points at a primitive. - damping ratio
- How strongly a spring resists moving, as a shape: 1 settles without overshoot, below 1 overshoots.
- single source of truth
- One place where a value is written. Everything else reads it, so nothing can disagree.
Step by step
01Find the numbers the site already uses
Search your CSS for times. Most are fallbacks inside var(--dur-base, 220ms), already tokens. The rest (the logo's 700, 500 and 350 ms) belong to a one-off choreography and stay.
02Add the purpose and spring tokens
In tokens.css add the purpose tokens, each pointing at a primitive, and four spring tokens written "stiffness ratio". A component then asks for a purpose: transition: opacity var(--motion-enter-duration) var(--motion-enter-ease).
03Add tokens.js
Create src/js/motion/tokens.js with token(), ms() and springOf(). A wrong token must throw with its name.
04Read the numbers
Run the third example. All three spatial springs overshoot by the same 0.15 %, whatever their stiffness (only the ratio decides it), the effects springs by 0 %, and Lumen's drawer by 0.39 % (about 1.2 px on a 320 px drawer).
Try it yourself
- 1
Read the three tracks
In "One token, many components" press Pause, Play all three, then Step to 110 ms. In our run the toast (220 ms) was at opacity 0.96, the drawer 182 px from its place (400 ms) and the chip (120 ms) at 0.40.
- 2
Change one primitive
Reload the example, press --dur-base: 440ms, then Play all three. Only the toast's two tracks (
transition · opacityandtransition · transform) become 440 ms; the drawer stays 400 ms and the chip 120 ms. With All three ×2 the drawer is 800 ms and the chip 240 ms.
Code & diagrams
Try it: interactive
A spring token as sliders
Set stiffness 380 and damping 34 (the drawer, ratio 0.87). Then try damping 20 (ratio 0.51) and read the overshoot: about 15 %.
The toast (enter), drawer (emphasis) and chip (exit) use purpose tokens. The script reads their durations, changes primitives on the root and reads again. Press **Play all three**, then the buttons.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
Console
Nothing logged yet.Expected console output
as shipped: toast 220 ms, drawer 400 ms, chip 120 ms
--dur-base 440ms: toast 440 ms, drawer 400 ms, chip 120 ms
all three doubled: toast 440 ms, drawer 800 ms, chip 240 mstokens.js inlined. A `var()` token resolves, `0.4s` becomes 400, and a unit-less or missing token throws an error that names it.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
Console
Nothing logged yet.Expected console output
raw getPropertyValue("--dur-base") -> "220ms"
ms("--dur-base") -> 220
ms("--motion-enter-duration") -> 220
ms("--dur-long") -> 400
ms("--dur-typo") -> RangeError: motion token --dur-typo is not a duration: "220"
ms("--dur-nope") -> RangeError: motion token --dur-nope is not a duration: ""
springOf("--spring-spatial") -> {"stiffness":380,"mass":1,"damping":33.91884431993519}
springOf("--dur-base") -> RangeError: motion token --dur-base is not "stiffness ratio": "220ms"Each token runs from 0 to 1 in the loop of spring.js, on a finer step. Rest: closer than 0.1 % and slower than 0.01 per second. Overshoot comes from the exact formula `exp(-π·ζ / √(1−ζ²))`. Material's values: @material/web 2.5.0.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
Console
Nothing logged yet.Expected console output
Material spatial fast stiffness 1400 ratio 0.9 overshoot 0.15 % rests after about 260 ms
Material spatial default stiffness 700 ratio 0.9 overshoot 0.15 % rests after about 350 ms
Material spatial slow stiffness 300 ratio 0.9 overshoot 0.15 % rests after about 480 ms
Material effects fast stiffness 3800 ratio 1 overshoot 0.00 % rests after about 190 ms
Material effects default stiffness 1600 ratio 1 overshoot 0.00 % rests after about 270 ms
Material effects slow stiffness 800 ratio 1 overshoot 0.00 % rests after about 370 ms
Lumen spatial (the drawer) stiffness 380 ratio 0.87 overshoot 0.39 % rests after about 490 ms
Lumen effects stiffness 1600 ratio 1 overshoot 0.00 % rests after about 270 msBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Misspell a primitive inside a purpose token
In "One token, many components" change --motion-enter-duration: var(--dur-base); to var(--dur-bse).
Myth vs fact
Myth
Tokens are just variables, so they add nothing.
Fact
The names are the point. A purpose name tells a developer which duration to use and tells a reviewer when it is wrong.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
getPropertyValue()on an unregistered custom property returns resolved text, sovar()chains work, but nothing checks the value. Registering a token with@propertyandsyntax: "<time>"(Topic 17.5) makes the browser reject a wrong value, at the cost of a rule per token. We keep tokens plain and test them. - ▸
Computed
transitionDurationcomes back as"0.22s", so comparing it with"220ms"is wrong. Read the token when you need the number you wrote. - ▸
A ratio also makes the overshoot a property of the token alone:
exp(-π·ζ / √(1−ζ²))has no stiffness in it, so 0.9 overshoots 0.15 % at stiffness 300 or 1400.spring.jssteps at 1/240 s with semi-implicit Euler, which under-reports that slightly: the drawer's 0.39 % reads about 0.13 % there (we ran both). That is under half a pixel on a 320 px drawer.
Remember this
- 1
The problem this solves: the Lumen site has many animations written by many hands. If each types its own
220msor0.25s, nobody can say what "the speed of Lumen" is, changing the feel means searching every file, and no test can check that two components agree. A token gives each number one name and one home. - 2
Two layers. The primitives from Phase 2 (
--dur-fast,--dur-base,--dur-slow,--ease-out,--ease-in,--ease-in-out) say how big something is. The new purpose tokens say what the motion is for:--motion-enter-*for things that appear,--motion-exit-*for things that leave,--motion-emphasis-*for a big, rare move. A purpose token points at a primitive withvar(). A component asks for a purpose and never picks a number, so designers can change what "enter" means without touching a component. The defaults follow Topics 2.4 and 5.3: arrive in 220 ms easing out, leave in 120 ms easing in. - 3
Springs can be tokens too. CSS cannot run a spring, but a custom property can store its numbers for JavaScript. Material 3 made springs tokens: a stiffness plus a damping ratio, in two kinds (spatial for things that move, effects for opacity and colour) and three speeds. Its values in
@material/web2.5.0 (md-sys-motion.scss, standard scheme) are spatial 1400, 700 and 300 with damping 0.9, and effects 3800, 1600 and 800 with damping 1. Lumen copies the shape:--spring-spatial: 380 0.87is the drawer's spring from Phase 9. - 4
Why a ratio.
spring()takesdampingin the same units as stiffness, sodamping: 34means a different shape at a different stiffness. The damping ratio is the shape: 1 settles with no overshoot, below 1 overshoots a little.damping = ratio × 2 × √(stiffness × mass); the drawer's 380 and 34 give 0.87. - 5
The JavaScript mirror.
tokens.jsreads the same custom properties withgetComputedStyle(document.documentElement).getPropertyValue(name), so CSS stays the single source of truth. A value written withvar()comes back resolved.ms()turns220msor0.4sinto milliseconds;springOf()turns380 0.87into the optionsspring()takes. Both **throw aRangeErrornaming the token** when it is missing or wrong: aNaNwould quietly make a zero-length animation. - 6
What tokens do not do. They do not replace the reduced-motion rules of Phase 7 (a duration cannot turn a slide into a fade), and a one-off choreography like the logo's 700, 500 and 350 ms draw (Phase 13) stays in its own rule.
Explain it without notes
Why use purpose tokens on top of primitives?
Why does a spring token store a damping ratio?
What does ms("--dur-nope") do, and why is that better than NaN?
Practice
Add a tooltip to the first example that slides 12 px and fades using the enter tokens. Which button changes its duration?
Write easeOf(name) for tokens.js: it returns the token's text and throws a RangeError when the token is empty.
Done when you can
You can explain primitive, purpose and spring tokens, and why components use only purposes
You can read a token in JavaScript and get a number, or a clear error
You can say what a damping ratio of 0.87 and of 1 mean
You know a token does not replace a reduced-motion rule