Motion is the line being drawn.
A kolam is not a static object — it is the record of a hand moving through a grid without lifting. That gives this system something most motion specifications lack: a physical referent. Duration is distance travelled, easing is one of three moves the line can make, and choreography is the order the line visits things. Nothing here was chosen for feel.
The premise
Three questions every motion system has to answer, and where this one gets its answers from.
How long?
Distance. 30ms per dot the line passes. A state change is four dots; a reveal is eight; a page is twelve. Durations are not round numbers picked for feel — they are counts.
What shape?
The move. A kolam line does exactly three things: it turns around a dot, it spirals inward and settles, or it releases and travels on. Three curves, no more.
In what order?
Path order. Things do not arrive together; they arrive in the order a single line would reach them, one dot apart. That is what makes a stagger interval derivable rather than tuned.
The three moves
Press each one. The loop travels the same distance in each case — only the curve differs.
neli · the turn
Symmetric in and out — the line enters a turn and leaves it at the same rate. Use for anything the user is directly manipulating: hover, press, colour change, toggle travel.
cubic-bezier(0.65, 0, 0.35, 1)
chuzhi · the spiral in
Fast departure, long settle — the spiral tightening toward its centre. Use for things arriving: reveals, expansions, content entering, page transitions.
cubic-bezier(0.33, 0, 0.15, 1)
vidu · the release
Slow start, accelerating away — the line leaving a dot behind. Use for things departing: dismissals, exits, collapse. Always shorter than the matching entrance.
cubic-bezier(0.5, 0, 0.9, 0.4)
--motion-exit is 120ms where --motion-reveal is 240ms. Arriving content earns the user's attention and can afford to settle; leaving content is already dismissed in the user's head and every millisecond after that decision is latency. This asymmetry is the single highest-value motion rule in the system.Duration is distance
One dot is 30ms. Everything else is a count of dots.
| Token | Dots | Value | Applies to |
|---|---|---|---|
| --nagarthal-dot | 1 | 30ms | The quantum. Also the stagger interval between siblings. |
| --nagarthal-1 | 4 | 120ms | Hover, press, focus, colour, anything under the pointer |
| --nagarthal-2 | 8 | 240ms | Reveal, expand, toggle travel, accordion |
| --nagarthal-3 | 12 | 360ms | Page and route transitions, pane changes |
| --nagarthal-4 | 20 | 600ms | The ceiling. Nothing a user waits on exceeds it — including any staggered sequence, measured end to end. |
Semantic roles
- --motion-state120ms neli
- --motion-reveal240ms chuzhi
- --motion-exit120ms vidu
- --motion-page360ms chuzhi
- --motion-stagger30ms
- --motion-cap600ms
Components reference roles, never raw durations — the same rule as every other token category.
The ceiling is a total, not a step
A common failure is capping each individual animation and letting a staggered sequence run for two seconds because nobody measured the whole thing. --motion-cap applies end to end: last element finished minus first element started. If a sequence would exceed it, the stagger shrinks — the count of items is never allowed to set the duration.
The draw-on is the worked example, and it is where this rule was found the hard way.
Choreography
A kolam line reaches things one at a time, in path order. So does a list.
Stagger · one dot between siblings
The interval is one dot. 30ms between siblings — enough to read as a sequence rather than a group, short enough that six items still land inside a third of a second.
The order is path order, not DOM order, when they differ. A grid staggers along the reading path — left to right, top to bottom — not down each column, because the metaphor is a single line travelling, and a line does not teleport between columns.
The total is capped. Beyond twelve items the stagger shrinks so the sequence still finishes inside 600ms; beyond about twenty, drop the stagger entirely and fade the group as one. A staggered list of fifty rows is not choreography, it is a wait.
| Items | Stagger | Total | Verdict |
|---|---|---|---|
| 3 | 30ms | 300ms | ideal |
| 6 | 30ms | 390ms | ideal |
| 12 | 30ms | 570ms | at the ceiling |
| 24 | 15ms | 585ms | stagger shrinks |
| 50+ | 0 | 240ms | fade as one group |
The toggle
The reference case: a component whose entire visual identity is the motion metaphor.
The control is a two-dot lattice at a local pitch of 3P. Its loop radius is 1.5P — exactly half that local pitch — so the meeting condition r = P/2 holds inside the component as well as across the system.
Off is the line looping the left dot. On is the same loop having travelled to wrap the right one. The travel is a neli: the turn a kolam line makes around a dot, which is why it uses the symmetric curve rather than a decelerating one — the loop is not arriving anywhere, it is passing around something.
The loop is hollow, so the dot it wraps stays visible inside it. That is not decoration: it is the difference between a line curving around a dot and a blob covering one.
Why this and not a pill
- It is derived. A pill track with a filled thumb has no relationship to the lattice; this has a local pitch, a loop radius, and a travel distance that are all lattice values.
- Position leads, colour follows. The loop moves 3P — a full local pitch. State survives greyscale, and the accent shift is reinforcement rather than the signal.
- The motion explains the form. You can see what the animation is of: a line moving from one dot to the next. A pill thumb sliding explains nothing.
Rules
- Immediate effect only. A switch that needs a save button is a checkbox. See Components → Choice.
- Wrap the input in its label so the text is part of the 44px target.
- Never animate the label. Only the loop travels; text that shifts on toggle reads as instability.
- Under reduced motion the loop jumps. Position and colour still carry the state — nothing is lost, only the travel.
What may animate
Two reasons to restrict this, and only one of them is performance.
| Property | Allowed | Why |
|---|---|---|
| transform | yes | Compositor-only. Does not touch layout, so it cannot move anything off the lattice. |
| opacity | yes | Compositor-only. The safest property in the system. |
| stroke-dashoffset | yes | The draw-on. Paints rather than reflows, and is the one animation the pattern layer owns. |
| color · background-color · border-color | yes | Paint-only. Cheap enough at these durations and cannot disturb geometry. |
| outline-color | yes | Focus rings may fade in. The ring's presence must not be animated — it appears at full strength or not at all. |
| width · height · padding · margin | no | Triggers layout every frame, and — more importantly here — animates through values that are not lattice steps. For 240ms the interface is off-grid. |
| top · left · right · bottom | no | Same problem. Use transform: translate(), which moves the painted result without touching the box. |
| box-shadow | no | Expensive to animate, and elevation is a three-step scale in this system — there are no intermediate depths to travel through. |
| font-size · letter-spacing | no | Reflows text and passes through sizes that are not on the √2 scale. |
Expanding and collapsing
The most common request that runs into this rule. Height cannot be animated, so:
/* transform-based: scaleY on a wrapper, counter-scaled content */ .panel { transform-origin: top; transition: transform var(--motion-reveal); } .panel[hidden]{ transform: scaleY(0); } /* or, where the content height is unknown, grid-template-rows — layout-animating but interpolates one value, not a box model */ .wrap { display: grid; grid-template-rows: 0fr; transition: grid-template-rows var(--motion-reveal); } .wrap.open { grid-template-rows: 1fr; }
The second is a documented exception: it animates layout, but the interpolated value is a fraction rather than a length, so no off-lattice dimension is ever produced. Where an exception is unavoidable, prefer the one that cannot violate the derivation rule.
The draw-on
The system's signature animation, and the place the ceiling rule was learned.
Each segment strokes itself in, staggered along the path. The line appears to be drawn rather than to fade in, which is the only animation in the system that is genuinely about the tradition rather than borrowed from it.
The stagger is computed, not fixed — and this is where the specification changed. The original implementation used a flat 6ms between segments. That is fine for a 3×3 kolam with twelve segments, and at 7×7 with eighty-four segments it ran 918ms. At 15×15 it ran 2.9 seconds, against a documented ceiling of 600.
The fix is to derive the stagger from the cap rather than fix it:
total = SEGMENT_MS + (n − 1) × stagger == CAP_MS stagger = (600 − 420) / (n − 1)
Now a 3×3 and a 15×15 both finish in exactly 600ms — the larger one simply draws faster, which is also what a practised hand does. Switch the grid size above and watch the readout.
| Grid | Segments | Stagger | Total | Before the fix |
|---|---|---|---|---|
| 3 × 3 | 12 | 16.36ms | 600ms | 486ms |
| 7 × 7 | 84 | 2.17ms | 600ms | 918ms |
| 11 × 11 | 220 | 0.82ms | 600ms | 1734ms |
| 15 × 15 | 420 | 0.43ms | 600ms | 2934ms |
Reduced motion
Reduced motion is not slower motion. It is the removal of motion that carries no information.
| Motion | Carries | Under reduced motion |
|---|---|---|
| Hover, press, colour | feedback | Instant. The colour still changes — the information is kept, the transition dropped. |
| Toggle travel | state | Instant. Loop position and colour still communicate on/off. |
| Reveal, expand | continuity of place | Instant. Content appears in its final position. |
| Stagger | sequence, weakly | Removed. All items appear together. |
| Page transition | spatial relationship | Removed entirely. Cross-fade is not a substitute — it triggers the same vestibular response. |
| Kolam draw-on | nothing — decoration | Removed entirely. The static kolam renders immediately; any accompanying status stays as text. |
Two mechanisms, because one is not enough
The stylesheet collapses every duration, and the generator checks the same query in JavaScript before running its draw-on. A CSS-only guard would leave the JS path setting strokeDashoffset on every segment — invisible, but still work, and still wrong.
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: .001ms !important;
animation-duration: .001ms !important;
animation-iteration-count: 1 !important;
}
}
Hard limits, regardless of preference
- Nothing loops indefinitely except a loading indicator, which stops when loading does.
- No parallax, no scroll-linked motion, no autoplay.
- Nothing flashes more than three times per second. The draw-on staggers; it never blinks.
- 600ms ceiling on anything a user is waiting for, measured end to end.
- No motion as the sole carrier of any information. If removing it loses meaning, it was not decoration and should not have been motion.
Do & don't
Make exits faster than entrances. --motion-exit 120ms against --motion-reveal 240ms.
Use one duration for both directions. Symmetrical timing makes dismissal feel like the interface is arguing.
Animate transform and opacity, and let the box stay where the lattice put it.
Animate height or padding. For a quarter of a second the component is sitting on values the system does not contain.
Measure a staggered sequence end to end and shrink the interval if it exceeds 600ms.
Cap each item and assume the sequence is fine. That is exactly how the draw-on ran for 2.9 seconds.
Remove decorative motion entirely under prefers-reduced-motion.
Shorten it to 150ms and call it accommodated. The preference is about vestibular response, not impatience.
Say "this is a reveal, so eight dots" in review.
Say "it feels a bit slow". Then nobody can tell whether the next version is right.
Testing motion
Motion is the least-tested layer of most design systems, which is why it drifts.
Automated
- Duration allow-list. Every
transition-durationandanimation-durationin the codebase must resolve to a--nagarthal-*value. A literal200msfails. - Property allow-list. Fail on any transition naming
width,height,top,left,padding,margin,box-shadoworfont-size. - Sequence ceiling. For every staggered sequence, assert
duration + (n−1) × stagger ≤ 600at the maximum realisticn. This is the check that would have caught the draw-on. - Reduced-motion path. Assert the JS guard exists wherever animation is set from script, not only in CSS.
Manual
- Run every flow with reduced motion on. Confirm removal, not shortening, and that nothing became unintelligible.
- Watch the longest list in the product load. Staggers that feel elegant at six items are a wait at forty.
- Throttle the CPU to 6× and check that nothing animating is also blocking input.
- Toggle a switch fifty times. Interruption behaviour is where easing choices actually show, and where a mis-set curve feels broken rather than merely wrong.