Stage 11 · Animation
Motion
The stage opened on a count rather than a design, and the count is why everything below looks the way it does: one surface of this product moved a great deal, the other fifty-four did not move at all, and the four curves the moving one ran on were declared where nothing else could read them. A scale derived from that would have been a scale derived from a sample of one.
So motion is now fourteen tokens, four verbs and one signature. The first pass was colour and nothing else, and the founder’s answer to it was the sentence this page is built around: “большинство анимаций я не вижу”. So things move now, and exactly one thing stops the movement becoming decoration: every object in this product travels the same 2px, and nothing anywhere travels two. Everything below is live - hover the real components, run the real curves.
The register: fourteen tokens, in tokens.css
Two levels for the curves and one for the durations, and the split is a decision rather than a habit. A second level is earned exactly where two roles would otherwise spell the same value, which is rule 4 of this system read from the other end: --ease-arrive and --ease-state are both expo-out today and can part company, so the curve is named once by shape and the jobs point at it. The two durations share nothing, so there is no shape level under them and inventing one would be a level with nothing in it.
Motion sits in the primitive half of the file and takes no dark pair, which is not an exception hunting for one. The primitive/semantic split of tokens.css is a split about the THEME axis: a colour is named twice because the same role points at a different value in the dark. No theme moves a curve. What motion has instead is prefers-reduced-motion, and that is a global kill in base.css rather than a per-token override.
| Token | Value | Its job |
|---|---|---|
| Level one: named by shape | ||
--curve-expo-out | cubic-bezier(0.16, 1, 0.3, 1) | covers the distance early, spends the rest closing the last few pixels |
--curve-expo-in | cubic-bezier(0.6, 0, 0.85, 0.2) | its mirror: slow to let go, then quick |
--curve-firm | cubic-bezier(0.45, 0, 0.22, 1) | accelerates and decelerates evenly, for a long distance |
--curve-spring | linear(), one overshoot of 1.5% at 66 | a spring, and the smallest one that still reads as an object |
| Level two: named by job | ||
--ease-arrive | → --curve-expo-out | everything that comes ON. What an object landing looks like |
--ease-leave | → --curve-expo-in | everything that goes OFF, and an exit is always shorter than an entrance |
--ease-settle | → --curve-spring | one place only: the deck closing over a cancelled plate. The one moment where something should feel like an object arriving at a stop rather than a value being interpolated |
--ease-travel | → --curve-firm | one journey only: the two figures crossing most of a viewport. Expo-out over 600px is a rocket that then creeps |
--ease-state | → --curve-expo-out | a control answering a pointer or a key. Its own role at the same value by rule 4: an entrance is a thing appearing, a state change is a thing already on screen saying it heard you |
| Durations, one level, named by job | ||
--dur-state | 150ms | the state change. Under about 100ms it reads as a cut and the transition buys nothing; over about 200ms a hover starts trailing the pointer, which on a list of fourteen rows is the opposite of calm |
--dur-press | 90ms | a press, and it is faster than a hover on purpose. A press answers a finger that is already down, so 150ms reads as lag where the same 150ms on a hover reads as soft. The release goes back on --dur-state: quick to take, calm to let go |
--dur-signature | 900ms | the brand’s one move, and the only duration here that is not answering a person. Long because it is an arrival rather than a reply: a hover has to keep up with a hand, and this has to be watched |
| Distance, one value, and it is the whole guard on the movement | ||
--nudge | 2px | the one distance anything in this product moves. An arrow advances by it, a mark lifts by it, a control presses down by it, and nothing anywhere travels two. 2 rather than 1 because 1px vanishes on a 2x screen at 36px, and rather than 4 because at 4 a list of fourteen rows starts jumping under the pointer instead of answering it. It is geometry, so it is a primitive read directly and has no role above it: what a nudge MEANS is written by the verb that spends it |
--dur-pulse | 1.4s | the loading placeholder breathing. A literal in skeleton-bar.css since stage 07, with the appointment written on that component’s own page |
There is no scale of durations, and this system will not invent one
A scale needs a range the product moves across, and this product moves across two values. The landing’s own numbers are not candidates: 47, 39, 33, 43 and 36 seconds for the marquee rows, and 13.3 to 19.3 for the eight strands, are LOOP LENGTHS, deliberately mutually prime so that nothing on that stage ever repeats in step with anything else. A loop length is not a duration in the sense a scale means it, and they stay literals in the file that owns the loop.
One principle is recorded with no token under it, on purpose: an exit is always shorter than its entrance. It governs the landing today through --ease-leave plus a shorter range, and it has no consumer in the app, because the app has no entrances. A token with no consumer is how a scale starts being fiction.
The four curves, running
A curve is the one value in this system that cannot be read. Nobody looks at cubic-bezier(0.16, 1, 0.3, 1) and knows that it lands, and nobody tells it from (0.45, 0, 0.22, 1) on paper at all. So they run here, on one clock, against linear as the control: linear is what the whole landing ran on until the founder’s note of 2026-08-15, and seeing it beside the other four is the entire argument in one second.
This stand runs at 900ms and the product runs at 150. A shape needs time to be seen; at the product’s own duration all five arrive together and the demo shows nothing. The number here is the stand’s, not the scale’s.
Watch the first two against each other and the rule reads itself. Measured off this stand at 1440, as a percentage of the distance: at one sixth of the run arrive has covered 69% and leave has covered 1%; at a third it is 91% against 3%; leave is still only at 45% when arrive has been home for a quarter of the run. That is the whole reason everything that comes ON takes the first and everything that goes OFF takes the second: a thing appearing should be there before you have finished noticing it, and a thing leaving should not snatch itself away.
Settle passes its stop, reads 101% two thirds of the way through and comes back down: a card seating itself, not a bounce. Travel is the only one that looks unremarkable here, and that is exactly the point: it stays within a few per cent of linear through the first third and only then pulls ahead. Over a distance ten times this one, expo-out reads as a rocket that then creeps, and this is the curve that does not.
The first verb: FADE. Colour, 150ms, every state
Hover these. They are the real components at the real duration.
Now tab through the same block. The ring arrives instantly and the fills fade, and that difference is the one exclusion this rule makes.
What moves, and what deliberately does not
| Property | In the rule? | Why |
|---|---|---|
background-color | yes | the most common state change in the product by a distance |
border-color | yes | every control that draws a resting edge draws its hover |
color | yes | links, the app bar’s own controls, the footer |
text-decoration-color | yes | the underline under an inline link, which is a line and moves with the ink |
outline, outline-offset | NO | the one exclusion, and it is an accessibility decision rather than a taste one. These appear in 20 of the 68 state rules and every one must land instantly: a keyboard user moves faster than 150ms per stop, so a faded ring is always one control behind the caret |
| anything that moves an object | not here | movement is real in this product, and it belongs to the component that owns the object rather than to a blanket: a disabled button is still a button and must not press, a loading row is still a row and must not lift, and base.css cannot know either. See the four verbs below. Nothing that changes the size of a box is on any list: no height, no margin, no padding, so no movement in this product ever reflows a line of text |
Where it is declared, and the three components that declare their own
base.css puts it on :where(a, button, input, select, textarea, summary, label), which is the focus ring’s list plus label. The one addition is the switch row: a label is not focusable, so it has no business in the ring’s list and switch-row.css draws that ring on the row through :has(input:focus-visible) instead, but .switch IS a label and it does change its fill under the pointer. Everything else is the same list for the same reason: these are the elements this system treats as interactive, and :where() holds them at specificity 0 so any component can still override without !important.
| Component | Its host | What it declares |
|---|---|---|
| Plan option | div in all 6 of its placements: 3 on the landing, 3 on upgrade.html | border-color. It is not a control, which is the same reason the consolidation refused to treat it as one |
| Preset tile | 6 button on add-subscription.html; 10 span, 6 of them on its loading state and 4 on the landing | border-color and background-color. One class with two element forms would otherwise fade on one and cut on the other |
| Alert item | 8 a on alerts.html; 4 div on alerts-loading.html | background-color, and the same argument |
One side effect was found rather than designed, and it is kept. A rule on color and background-color also fires when the THEME changes, so flipping Light to Dark on this stand now crossfades every control while the grounds behind them snap. Measured on button.html: 50ms after the flip the primary button is already at rgb(99,169,179) on rgb(37,44,45), near its final pair and never below its contrast floor at any frame. It reaches nothing in the product, which has no runtime theme switch, and where it does reach it reads as the controls answering first. Kept, and written down here so the next person does not have to work out whether it was intended.
All three read --dur-state and --ease-state, so none of them can drift from the rest of the product. The rule is U12 in Architecture: does the hover host answer to the element list? then nothing to write. Does it not? then one transition in the component file, reading the two tokens and never a literal.
The other three verbs: advance, lift, press
The colour pass shipped first and the founder’s verdict on it was that it could not be seen. Three verbs answer that, and they were chosen so that the movement always has a cause: something moves only where the movement says what is about to happen. One distance holds all three - --nudge, 2px - which is what keeps a product with movement in it from becoming a product with decoration in it.
| Verb | What moves | When | Who owns it |
|---|---|---|---|
| Fade | colour: fill, edge, ink, underline | every state, 150ms | base.css, and three components whose host is not a native element |
| Advance | a direction cue, one nudge toward where it points | its target is hovered | step-forward link (down), nav row, door, alert item (right) |
| Lift | an identity mark: up one nudge, and out to 1.06. Or the button itself, up one nudge and no scale: it is the object you are about to push, so it rises to meet the pointer | the target it stands in is hovered, or the button itself is | logo in a row, an alert, a tile or a door; the destination icon in a tab; the button, since 2026-08-16 |
| Press | the object itself, down one nudge, at 90ms. On a lifted button that lands it back on the surface rather than below the line | held | button, row, nav row, door, tile, alert, segment, cut control, tab, checkbox (as a scale) |
| The crop not a verb, the brand’s own | the window over the letter opens toward the whole d and settles back into crop A, once, holding no pose at either end of the pointer | the lockup is hovered, word included | brand mark. It is the signature played on demand: same distance, same length, same landing curve |
Advance, live. Hover each one
The chevron and the arrow both point right and both go to another screen, so both advance right. The step-forward link’s arrow points down and advances down, because that link goes further down the same page rather than to another one. A cue advances toward what it means, which is the whole rule and the reason there is no fifth direction.
Lift, live. Hover the row, then the tile
The mark answers and the figure never does. A merchant’s mark is the identity of the thing you are about to open, so it is the right part of a row to come forward. The amount beside it stays exactly where it is, and that is a rule rather than an omission: a number that moves is a number you re-read, and this product exists to stop people re-reading their money.
Press, live. Hover one, then hold it down
Down at 90ms and back at 150: quick to take, calm to let go. That asymmetry is the same one --ease-leave carries on the public page, read at a hundredth of the scale. A press has to answer a finger that is already down, so the hover’s own 150ms reads as lag here where it reads as soft there.
On the button the lift and the press are one gesture in two halves, added 2026-08-16 after the founder hovered one and saw nothing. Measured before it was changed: a secondary button’s hover takes its fill from #ffffff to #f4f7f8, a step of about two per cent. Real, and at arm’s length nothing. So the button rises to meet the pointer and the press puts it back on the surface - which is why the pressed state on a hovered button is level rather than a nudge below the line. The distance rule holds untouched: rest to hover is one nudge, hover to press is one nudge, rest to press is one nudge, and nothing anywhere travels two. The lift sits behind @media (hover: hover), so a tap on a phone and a Space on a keyboard both keep the plain press exactly as it shipped.
What deliberately does not move, and why
| What | The ground |
|---|---|
| a disabled control | moving it answers a press the product will not honour. The disabled segment of the range picker is the Pro range on a Free account |
| a loading form | a placeholder has nothing to press. skeleton-bar.css has carried this rule in words since stage 07 and enforced only the cursor; on 2026-08-16 the row, the alert and the tile scoped their hover to the pressable element form, so all three now obey it |
| the current tab | it is where you already are, so answering a pointer on it offers a journey that does not exist |
| a figure: the amount, the monthly total | a number that moves is a number you re-read |
| a card, on hover | a lift needs a shadow to read as one, and the Flat Paper Rule gives this system exactly one shadow, spent elsewhere. What answers on a card is the edge, the fill and the arrow |
| the focus ring | a keyboard user moves faster than 150ms per stop, so a faded ring is always one control behind the caret |
| anything that reflows | no height, no margin, no padding is on any list in this product, so no movement here ever pushes a line of text |
Under prefers-reduced-motion all four verbs stop being animations and become states: the guard in base.css takes every duration to 0.01ms, so a hovered mark is simply already lifted. That is deliberate. A 2px offset that appears instantly is not motion, it is a state exactly like a colour, and removing it would take the answer away from the person who asked for stillness rather than giving them one.
And a fifth thing, which is not a verb: the signature
The founder, 2026-08-16: “давай на логотип сделаем анимашку, у нас где-то было расписано как делать анимацию”. It was: the crop direction, the one chosen out of thirteen, is a single letter with a window cut out of it and one sentence holding it together - the tighter the crop, the more abstract the mark. That page demonstrates it with an eight second loop panning across all three canonical windows.
What ships is one move, once, on arrival. The window opens to the whole letter and closes into crop A, so the mark begins as a d you can read and becomes a mark you cannot. The last letter of the word takes petrol at the moment it lands.
Why it is not a fifth verb. Fade, advance, lift and press are answers: something moves because a person did something. This is an arrival, nobody asked for it, and that is exactly why it is fenced rather than free: one surface (the public page’s bar), once per load, never inside the app. The modifier on the lockup is the fence, and the 54 app pages that carry a lockup do not carry it. A mark that moves every time you open the product is not a signature, it is a fidget.
--ease-settle gets its second consumer here, and it is the curve’s own brief: it was declared for an object arriving at a stop rather than a value being interpolated. A window closing onto a letter and clicking into place is that event. Full specimen, both at the shipping size and magnified, on Brand mark.
And the same gesture played, on every screen: the crop answers a pointer
The founder, the next morning, hovering the lockup: “при ховере я не вижу анимации”. Correct, and correct by construction - the mark was declared not interactive and it still is not a target. It answers anyway, and it answers in its own words: what moves is the window, not the mark. A mark that rose and grew would be a lift, and a lift says the object is pressable. It runs on the lockup, so hovering the word plays it too - the two halves are one object.
The first version held the window half open and the founder killed it within the hour - “не виглядає це, м’яко говорячи, не очень” - and the reason is worth more than the fix. The field of the mark is the same colour as the bar behind it, so what a person sees is the ink and only the ink. Open the window part way and the letter shrinks off the frame’s edges: the rounded square stops being one and a bowl with a stub of stem is left standing. There is no good resting crop other than crop A. So the hover holds no pose at all: it plays the crop and comes home, both ends at crop A, every frame between them a frame of motion.
One crop number, two rules, and no new token. The signature travels from --crop-whole to 1; the hover goes there and back, over --dur-signature, opening on --ease-leave and landing on --ease-settle - the signature’s own curve. This is the one place where an arrival and an answer share a gesture, and they can, because it is the same gesture: given by the page on arrival, asked for by a person here.
And the hover found a real bug in the signature, which is worth more than the hover. A CSS animation list is matched by position: while the two rules shared one slot, taking the pointer off the public page’s lockup put brandclose back into that slot and the arrival replayed on a mouse-out - “once per load”, broken by a hover. They now sit in two slots, with the signature’s half spelled identically in both rules through --sig-name, so neither rule can restart the other.
And a sixth thing, added the day after: the vignette that loops
The founder, 2026-08-17, on the first of the three How-it-works tiles: “сначала кнопка по центру квадратик и плюсом нажимается и появляются аппликейшены по очереди таким образом и плюсик в конце потом плюсик увеличивается и снова анимация проигрывается, короче ее надо сделать loop”. It is the second loop this product has, and the first one that is not the brand: the crop page’s eight second pan is a demonstration of a direction, this one is a block of the public page.
It passes the rule the four verbs were chosen under, which is the only reason it is allowed to exist. The movement always has a cause. The heading under this tile says Connect or add and the paragraph says add your subscriptions yourself from 400+ services, and the still picture showed the RESULT of that sentence and never the act: eight tiles already there, with the one control among them reading as an eighth logo. The loop shows the act. The plus is alone on the panel, it is pressed, seven services arrive in the order a person would add them, and the plus ends where the still picture always had it. The cause is the press and the effect is the grid filling, which is what the block claims in words two lines below it.
Nine seconds, and 42 per cent of them are the still picture. From 2.97s to 6.75s all eight tiles stand exactly as the static markup draws them. That is the shape that decides whether a loop is a vignette or a carousel: the resting state has to be the majority state, and this product’s first principle is calm over clever. The duration is a literal in landing-steps.css by this system’s own rule for a value with one consumer, the same rule that keeps the lift’s 1.06 out of the register.
The two distances are computed from the grid, not measured off a screenshot. The plus is the eighth tile, column 4 of row 2, and the centre of a four by two grid is one and a half tiles and one and a half gaps to its left, half a tile and half a gap above it. A percentage on translate resolves against the element’s own border box, so -150% is one and a half tiles whatever the track computes to, and this track is minmax(0, 56px) and does shrink. The gap reads --space-16 rather than 16. Nothing in the rule knows a pixel of the layout.
translate and scale and not transform, because those are the two properties logo.css already animates on this same element for the lift, and written as a shorthand the two would overwrite each other. An animation outranks the transition declared there, and these tiles have no hover host in any case: the lift is scoped to .door:hover and this grid sits in a div.sdemo.doors, a different class.
animation-fill-mode: backwards is load-bearing and it is the whole reason the first cycle is not wrong. Each tile waits out a delay of up to 2.7s before its animation starts, and with the default fill mode it would render its static self, visible, for that whole time and then blink out the moment the animation began. backwards makes the delay show the 0% frame, which is the hidden one. There is no forwards: an infinite animation has no end to fill.
And the reduced-motion kill is by NAME here for a stronger reason than the entrances have. base.css zeroes the duration and forces one iteration, which would land this loop on its LAST frame: the plus alone in the middle and seven services gone. That is not a stiller version of the picture, it is a different picture with the content removed. With the name killed the tiles render exactly as the markup writes them, which is the still vignette this block shipped with. Verified by cancelling the animations in the browser: all eight tiles read opacity 1, no scale, no translate.
Read off the clock rather than watched. Sampling a running loop is a race, so the eight animations were paused and driven to twelve moments of the nine seconds: at 0.9s the plus is alone, centred, at 1.3; at 1.08s it is at 1.08, pressed; at 1.9s it is home and one service has landed; at 2.4s four have; at 2.97s all seven; at 6.75s all seven still; at 7.92s the last has gone and the plus is still in its slot; at 8.99s it is centred at 1.3 again, which is the 0% frame to three decimal places, so the loop has no seam. 0 console errors on the page.
And the press got an answer an hour later, which is the part that makes it a movement rather than a sequence. Founder: “и при нажатии хочется на заднем фоне какую типа волну пустить в бренд цвет”. The press had been the one beat of the cycle with no consequence on screen. A petrol wave now leaves the plus at the frame it goes down and the first service lands inside it. A FRONT and not a disc: a flat fill scaling up is a colour swelling, an annulus scaling up is something travelling, and it is one radial-gradient(closest-side, ...) on one pseudo-element with both edges feathered. IT WAS REWRITTEN TWICE IN ONE EVENING AND THE THIRD SHAPE IS THE ONE THAT SHIPPED, which is worth keeping whole because the two rejections together say what this panel can carry. First one thin ring at 0.45, rejected as a sonar ping - “давай более нежную сделаем как будто капля упала в воду”. Then two thin rings, a drop's train of fronts, rejected as well - “мне не нравится эти волны, давай оставим одну и не как линию, а как заполненную, такую на всю карточку круг идет”. So the answer is not a front at all, it is a field. A ring, however soft, draws an EDGE, and an edge on a white panel is a shape a person tracks: at any frame the eye has an outline to follow, which is exactly why one ring reads as sonar and two read as ripples somebody counted. A filled circle has no edge to follow at this alpha; what crosses the panel is a change in the ground. 0.10 and not 0.26, and the area is the whole reason: the ring painted about a fifth of the circle and this paints all of it, so holding the same apparent weight over five times the area means roughly a fifth of the alpha, about six values per channel at the peak. --ease-arrive is the one thing that survived both rewrites - the circle is thrown out by the press and the surface takes it back - and --ease-travel is back to one consumer, because its reason was about a FRONT crossing a distance and a field has no front.
It reads --bg-action, and the role is the argument rather than the colour. Petrol has four permitted jobs and the primary action is one of them; this is the primary action of that picture answering a press, so it takes the fill role that answer already has - which also means it is a real colour in both themes with no new token. Checked with a theme control rather than assumed, because these pages boot their theme from localStorage and setting an attribute is not setting the theme: the page ground moves rgb(238,243,244) to rgb(14,21,23) and the wave follows the role from #1c6a76 to #6bb0ba. Peak 0.45 at one frame, down from 0.55 after one look: at that weight the ring was the loudest thing in the section for a third of a second, which is a banner and not a beat.
And step two browses itself, added the same evening. Founder: “значит смысл в чем это будет список который двигается, все оставляем так же, просто внутри блока одна подписка уходит вверх, а внизу добавляется другая, будто двигаем список, просматриваем наших подписок”. It passes the same test: the heading says See it all, calmly and the paragraph is about a list a person scrolls, and the still picture showed three rows with no reason to believe there were more. The movement is the word ALL, drawn.
Four rows is the minimum and that is arithmetic rather than taste. The panel is a three-row window, so the moment the top row leaves, the bottom slot is empty and something must be entering it in the same 600ms - which cannot be the row that is leaving. The markup gained a fourth, Notion at $8.00, taken from the canonical dataset in voice/docs/microcopy.md rather than invented. With motion off it is clipped by the overflow: hidden the panel already declares, which is what an unscrolled list looks like, and the still picture is unchanged to the pixel.
The rows come out of flow so that ONE keyframes can drive all four. In flow each row's rest position differs, so "move up one slot" is a different pair of numbers per row and would need four keyframe blocks that have to agree with each other. Absolute and left/right pinned, every row's origin is the same point, the slots are four numbers written once, and the phase is carried by a negative animation-delay: -6.75s, -4.5s and -2.25s are three quarters, a half and a quarter of the cycle, so at t=0 the four already stand at slots 0, 1, 2 and below. No fill mode, no first-cycle special case, and the order on screen at rest is the order in the markup.
The slots are -1, 64, 129, 194 and the -1 is load-bearing. Every row carries a top hairline here, the first one included, because any of the four can end up in the middle of the stack and a missing separator is the one thing this movement could break. That would put a hairline at the very top of the panel, one pixel under its own border, reading as a double rule. Sliding the whole ladder up by one pixel clips exactly that hairline and nothing else. And the wrap is instant and happens off-panel: a row leaves at -66, its own height above the top edge, and one thousandth of a percent later it is at 194, below the bottom edge - both outside a box that clips, so the jump cannot be seen.
This one is guarded by no-preference rather than killed by name, which is the opposite of the two above, and the reason is that it changes LAYOUT and not only motion. Killing the animation alone would leave four absolutely positioned rows stacked on one point. Under reduced motion none of those rules exist at all: measured with the media feature asserted in the page, the four rows read position: static at 0, 65, 130 and 195, zero animations under the panel, and the fourth simply falls off the bottom.
And step three cancels, in a loop. Founder: “по принципу как второй, но сложнее: по смыслу нам надо показать, что например мы отменяем нетфликс, он canceled становится и уходит вверх, и все двигается вверх, и так по кругу”. It is step two's machinery plus one beat, and the beat is the point: the row standing at the TOP changes state before it leaves. The heading is Cancel and save, and the still picture could only ever show the RESULT of that sentence - one row already cancelled, with nothing to say when it became so. The loop shows the verb.
The two ends of a row share one slot, and which is visible is the state. Every row carries both its amount and its Cancelled chip now. The chip is the one in flow and the amount lies over it, absolutely placed at the same right edge, and that way round is what keeps the row's geometry still: the chip is the wider of the two, so the space is reserved for the wider one and nothing reflows when the state changes. The other way round would put a chip with a background on top of the name, which is a defect wearing the look of a bug.
The state is four properties on three elements and not one opacity on the row, which is this component's own oldest rule: an opacity on the row takes the Cancelled chip down with it, and the chip is the one thing in that row a person has to be able to read. The mark dims to 0.45, the name steps back to muted and to 400, the amount goes and the chip arrives. --ease-state and not --ease-arrive, on this register's own line: an entrance is a thing appearing, a state change is a thing already on screen saying it heard you, and nothing arrives here.
The two ends hand over, they do not cross-fade, and the first cut did cross and looked exactly like the bug it was not: for 180ms "Cancelled" and "$11.99" were both half painted in the same 70 pixels, one word legibly on top of another. Two texts sharing one slot have to take turns. The amount is gone before the chip begins, and that is measured rather than intended: driven over the whole beat at 15ms steps, the highest simultaneous visibility of the two is 0.000.
The delay is set on the row and on all of its children at once, > *, because animation-delay does not inherit and five elements that have to agree about which beat they are in cannot be given the number five times without one of them drifting.
What it costs, stated rather than left out: it runs off screen too. There is no CSS that pauses a time-driven animation on visibility, and animation-timeline binds progress to scroll, which is a different movement and not this one. The bill is a compositor tick on eight elements that browsers already throttle in a background tab, against a script on a page that would otherwise need none.
The census this came out of
Taken 2026-08-16 by script over design/system/, the 56 pages of design/, design/story.js, the stand, and wireframes/ read-only, with comments stripped before anything was counted. Motion is the one artefact in this system a screenshot cannot audit: a still frame of a scroll-driven stage and a still frame of a broken one look the same.
| What | At the count | Today |
|---|---|---|
transition declarations in the product | 0 | 19, in 15 files: one blanket in base.css and the rest in the components that own a verb. Recounted 2026-08-16 after the hover pass; it read “1 and 3” while the four verbs were being written |
Motion tokens in tokens.css | 0 | 14: 4 curve shapes, 5 jobs, 4 durations, 1 distance. It read 11 until the recount of 2026-08-16, which is the register above counted by script rather than from memory |
| Files declaring any motion | 8 of 75 | 23 of 75, recounted 2026-08-16 |
@keyframes in the system | 31, and 30 of them one page’s | 33 at stage 11, and 41 since 2026-08-17: one dead one deleted, three added at the stage (the pulse’s neighbour and the brand’s two halves), and eight by the three vignettes - two for step one’s tiles, one for the press, one for both lists, and four for the cancel. The page’s 30 are untouched, because keyframes belong to the choreography that owns them. The figure counts components/ and it never said so: recounted by script with comments stripped, and since 2026-08-18 it reads 39 in components/ and 40 in design/system/, the difference being pulse in base.css. The two that went are mrise and mpose, the entrances of the landing's separate mobile act, deleted with the act when the phone started running the same stage the desk does |
| Easing curves declared anywhere | 4, all on one selector in one file | 4 shapes and 5 jobs, in tokens.css, readable everywhere |
| The same expo-out written out by hand | 4 more files | 0. facts, paths, plan and steps read --ease-arrive |
| State-carrying components that cut | 26 of 28 | 0. All 68 state rules fade what they change, except the ring |
The drift the token prevents had already happened. A custom property declared on one component’s pin cannot be read from another, so four of the five rebuilt blocks of the public page each wrote cubic-bezier(0.16, 1, 0.3, 1) themselves. Five files, one curve, one name and four literals: that is the argument for this register in one line, and it was a fact in the code rather than a risk in the future.
The census also found one dead keyframe and it was deleted the same day. @keyframes srise in landing-story.css, defined and named by nothing: it was the story head’s entrance, kept after that file recorded the decision that the head does not make one. Found by reading every @keyframes in the system against every animation-name in it, which is part of the census script rather than a thing somebody remembers. 31 defined, 31 named.
The guard, which stopped being theoretical
base.css carries the product-wide prefers-reduced-motion block: animation-duration: 0.01ms, animation-iteration-count: 1, transition-duration: 0.01ms, scroll-behavior: auto, all !important, on *. Until today its transition half reached zero declarations, because there were none. It is load-bearing from this stage on, which is the right order to have built it in: the guard went in before the thing it guards, and it went in because it had once been written at the wrong specificity and every skeleton pulsed forever for a person who had asked the operating system for stillness.
Measured after the change, with the preference set: the state transition computes 0.00001s and the skeleton stops. Eight further prefers-reduced-motion blocks live in the six landing files and one more in design/story.js, because that page has choreography a blanket duration cannot undo on its own.
What is still open, and it is two of the four
The census put four questions on this page. Two are answered above, in the code. The other two are answered no, and not yet respectively, which is a decision rather than a gap.
| Question | Answer |
|---|---|
| Do the four curves become tokens, and at which level? | Done. Two levels for the curves, one for the durations, and the test for when a second level is earned is now rule 1 of design/system/CLAUDE.md |
| Does the app get motion at all, and what kind? | Done. One state change: colour over 150ms, nothing that moves an object, and the ring excluded |
| What happens between two states that are documented as two pages? | Nothing, and that is the answer for now. The product has 7 loading, 7 empty and 7 error pages, and every state change it documents is a separate file. Naming a transition between them would be designing a runtime this product does not have yet: there is no router, no client state and no place for the movement to live. The honest form of the answer is that a state change in Tendd today is a page change and carries no motion, and the question comes back when there is an application to attach it to. Written down rather than left silent, which is exactly the thing the census caught the app doing |
| Is the landing’s language the product’s language? | Partly, and only the part with a consumer. The four curves are shared because five jobs now read them. The rest of the landing’s vocabulary - the entrances, the ranges, the choreography - stays on the landing, because rule 4 exists precisely so that a scale is not derived from a sample of one. The day a second surface needs an entrance, it will be designed against two surfaces instead of one |
Every component, walked with a pointer
The founder, 2026-08-17: “проверь все атомы что у них есть анимация”. So this is not a claim, it is a sweep: all 71 rows of the component registry, each opened on its own stand, probed at rest, under the pointer and under a held button, with the computed style read off the component and every element inside it, pseudo-elements included. Where a stand has no live specimen - the landing organisms, the shell, the skeleton - the probe moved to the product page that carries it. Script: scratchpad/atoms2.cjs, with three follow-up passes for the cases where the first probe landed on the wrong element.
Result: everything that is a target answers, and nothing that is not moves. The follow-ups mattered - the first pass reported the tab bar and the merchant mark as dead, because it had hovered the CURRENT tab and a mark standing outside a row. Both answer correctly when hovered the way a person hovers them.
| What answers | How, measured | Where |
|---|---|---|
| The brand | the crop plays: brandplay runs, scale reaches 0.4369 and comes home to crop A | .lockup, and hovering the word plays it too |
| Lift, up one nudge | translate: 0 -2px, and scale: 1.06 where the object is a mark | the merchant mark inside a row, an alert, a tile or a door; the destination icon of a non-current tab; the button, with no scale |
| Advance, toward what it means | after.translate: 0 2px on the step-forward arrow, and the same one nudge right on the chevrons | .next, .navrow, .door, .alert |
| Press, one nudge down | translate: 0 2px while held, and 0 on a button that was already lifted | .btn, .row, .navrow, .door, .tile, .alert, a range segment, a tab |
| Press, as a scale | scale: 0.94 | .check, the one control too small to travel |
| Fade and nothing else | fill, edge or ink over 150ms - a row goes to #f4f7f8, a field edge to #5a686c | the text input, the select, the switch row, the plan option, the app bar’s back link, every inline link, the FAQ summary |
| the focus ring | outline-width: 0 → 2px, instantly | every focusable control, and it is off the fade list on purpose |
And what deliberately did not move, confirmed one by one rather than assumed: the amount and the big total, the chip, the eyebrow, the label, the muted and quiet lines, the readout, the meta row, the chart placeholder, the detail hero, the charge list, the pair list, the promise list, the summary, the text block, the share card, the merchant chip group, the group head, the numbered steps, the card on hover, the current tab, the current range segment, and the landing’s switch demo, which is inert and aria-hidden because it is an illustration of a control rather than one. The skeleton bar moves and only breathes: its fill pulses at --dur-pulse and it answers no pointer at all.
One honest note, and it is a note rather than a defect. The checkbox’s own hover paints accent-color, #1c6a76 → #175a64, which is the colour of the box once it is checked: hover an unchecked box on its own and nothing on screen changes. Counted across the coloured product: all 7 checkboxes sit inside a label.switch, and that row fades under the pointer, so nobody in this product meets a dead hover. The day a bare checkbox appears on a screen, this becomes a real hole and the answer is the row’s, not the box’s.
Checked on two instruments, and both of them found something
Claude in a browser and on its own scripts, Codex read-only on the source, taken independently and merged. Five findings, all five verified by re-reading the place, all five closed. Nothing was dropped at verification this round.
| Who | Was | Became |
|---|---|---|
| Claude, recounting its own published figures | Four element-form counts stated occurrences as SCREENS. “an a on eight screens and a div on four”, “a span on ten screens”, “a div on all six screens”, “the row is an a on 47 screens”. Every one was a tag count from a grep | Recounted per screen. The alert is an a 8 times on one screen and a div 4 times on one other; the tile a button 6 times and a span 10; the plan option a div in all 6 placements; the row an a in 47 of 60. And the corrected count changed an argument: the non-native forms are almost all LOADING states, which is a better reason to declare on the class and a worse look for the hover they carry |
| Claude | “19 of the 68 state rules draw a ring”, published in five files | 20. Recounted with comments stripped |
| Codex | The denominator did not match the corpus it named. The census listed base.css, tokens.css, index.css, 69 components and 3 patterns, which is 75 files, then reported “7 of 72” | 8 of 75, and the numerator moved too: the ratio had silently dropped base.css, which carries the pulse keyframe and the guard |
| Codex | A promise this stage had already kept. base.css still read “Stage 11 owns motion and will put durations behind tokens” beside the two tokens it had just put them behind | Rewritten to what happened, plus the fact worth keeping: the guard overrides by DURATION and not by name, so a token added tomorrow is guarded the day it is written |
| Codex | “The selector is the same one the ring uses” was false. The ring takes six elements; the transition takes seven | The seventh is label, and it earns its place: a label is not focusable, so it has no business in the ring’s list and switch-row.css draws that ring through :has(input:focus-visible) instead. But .switch IS a label and it does change its fill under the pointer |
| Codex | U12 named three components and none of them carried a Limits item, which architecture.md requires of every rule in that table. The notes were written into their States sections instead | A Limits sub-item on all three, in the house form, quoting the rule and linking back. The States note kept only the half that is about the component |
What the split says about the instruments. Claude found what only a recount finds, on figures it had published itself; Codex found three things that are invisible unless you read one file against another. Neither list would have been produced by the other, which is the whole reason the rule says two.
Round 2, the same day: the hover pass. Six findings, six closed
Run again after the crop hover and the button lift. Codex found all six and the browser pass found none it had not already fixed while building, which is worth writing down rather than smoothing over: this round was prose against a corpus, the half a browser cannot see. Every finding verified by re-reading the place before it was touched.
| Who | Was | Became |
|---|---|---|
| Codex | A cascade claim that was false on one of the two files it was written in. brand-wordmark.css said its both would have stopped the lockup answering a hover | Only the mark’s half blocked it: the word’s half paints color on .wordmark .hi while the hover moves scale on .brand::before - a different node and a different property. The change stands on narrower ground: a forwards fill holding a value the base rule already paints shadows that property for nothing |
| Codex | “The app’s 32 lockups do not carry .signature”, in six places | 54. Counted 2026-08-16: 57 pages in design/ carry a lockup and exactly one of them, the public page, carries the modifier |
| Codex | “a link on none of the 33 screens”, in four places | 55, the same count read from the other end. Not one of the 55 lockups is an anchor, so the conclusion held and the denominator did not |
| Codex | “36 of 40 deterministic frames” called all forty deterministic | 40 captured, 36 deterministic and all 36 byte-identical. The other 4 are the public page at two widths in two themes and are not deterministic at all |
| Codex | The README said thirteen motion tokens where the register on this page says fourteen | Fourteen. The row had dropped --dur-pulse on the way from the register to the summary |
| Codex | Three live counts in the census table below had gone stale in a day: “1 transition and 3 in components”, “11 tokens”, “11 of 75 files”, and a keyframe row still saying “unchanged” | Recounted by script: 19 transitions in 15 files, 14 tokens, 23 of 75 files, 33 keyframes. They were written while the four verbs were still being added, which is exactly the trap the live-count rule exists for |