Skip to content
GLEE/Motion Canon

Motion Canon

Every canonical GLEE motion primitive, rendered live, beside the ad-hoc values it replaces. This page is the decision surface: a primitive becomes canon by being judged here, not by appearing in a diff.

— 140 ms is too fast to judge by eye. Slow-mo scales only the four duration tokens; it changes no rule.
The law. An implementation agent selects a primitive by meaninggm-hover-lift means this surface is interactable and comes toward you, not "translateY looks nice". It may not write inline motion on an interactive state, across any of the four channels: transform:, the standalone scale:/translate:/rotate: properties, transitioning layout geometry to fake a scale, or JS assigning motion at commit time. Need something new? Prototype it here, get it judged, add it to glee-motion.css with its contract — then use it. Adding a primitive is a deliberate change to the design system. Inventing one inline is drift.
Found in the estate, 2026-08-07 — the Agora card. Stage 1: .entry.nudge { scale: 1.09 } on an overshooting spring, held. Stage 2, 550 ms later in JS: animate width/height/top/left toward ~1.85× and shove the neighbours. Two magnitudes for one gesture, two owners of size on one element, spread across three channels chosen specifically to avoid fighting the entrance animation for transform. Every decision defensible; the result is "a slight enlargement followed by another excessive zoom."

Why — the drift this replaces

Every one of these was a locally reasonable edit. Together they are six answers to one question. Hover each.

−1px
os_shell .tile
−2px
style_engine .card
−3px + 1.012
scene-control
−4px + 1.012
workstation-module
−4px + 1.015
.glee-object
scale 1.04
bare zoom

6 vocabularies for one gesture · 22 hardcoded durations across two stacks · 0 shared motion tokens between them.

And the compound failure — an ancestor that scales wrapping a descendant that also scales. The one transform owner rule exists for this:

post card 1.015inner 1.09 — the second zoom
canonicalinner holds still

The three extremes — does one magnitude survive?

Captain's test: a −3px lift that feels excellent on a project card can feel absurd on a 28px control and invisible on a 400px scene object. Hover every cell in both rows. Row 1 is one magnitude everywhere. Row 2 is the same semantic primitive with a magnitude register. The numbers under each cell are the lift as a share of the element's own height — that ratio is the measurable part of the argument.

tiny control · 28pxcard / post · 142pxscene object · 400px
one magnitude
gm-hover-lift everywhere
a post
scene / media object
magnitude register
same meaning, sized to fit
a post
scene / media object

If row 1 reads consistently across all three, the canon stays one magnitude and .gm-subtle / .gm-emphatic come back out. If it does not, the registers stay — and the thing that must never come back is a component picking its own number.

Hover — pick exactly one

gm-hover-lift
The default for anything card-shaped: posts, tiles, panels, result rows. Y only, −3px, 140 ms, alive curve.
card / post / tile
Never scales — a card is a surface, not an object, and scaling text-bearing surfaces resamples the glyphs.
gm-hover-object
For canvas objects the user can actually grab. Scale is the affordance for "this is a thing you can pick up".
draggable object
Never on a static card. Spend the scale only where dragging is real.
gm-hover-tint
No geometry at all. Links, nav, dense list rows — anywhere lifting would make a scrolling column shimmer under the cursor.
Receipts Proof runs Projects
Never lifts. Never changes opacity.

Press & focus

gm-press
scale(0.985), 80 ms, precise curve. Composes with a lift: the object dips from where it is, it does not snap to origin first.
hold me down
Never bounces back past origin.
gm-focus-ring
A ring, 80 ms. Tab to it with the keyboard.
Focus never moves anything. Moving on focus breaks keyboard tracking.

Open / close

gm-expand
280 ms to true content height via grid-template-rows — no measured pixel value, so it never fights a resize.

Frozen corpus, 123 independent tasks, scorer-pinned. Re-run byte-identical.

Never animates to a hardcoded max-height.
gm-enter / gm-exit
Content arriving in place: streamed rows, new receipts, agent handoffs. The exit is quicker (140 ms) and accelerates.
Exits never reuse the entrance curve — that reads as sticky.
gm-route-in
Route arrival. One primitive for the whole region — a route change is not a per-element cascade.
page region
Never staggers children. Stagger is a loading pattern, not a nav one.

Selection, drag & drop

gm-selectable
Border + inset ring. Click to select.
select me
Selection never moves the thing selected — a moving target under a click is how you get the wrong row.
gm-drag-pickup / gm-dragging / gm-drop-settle
Pickup 80 ms, carry with no transform transition, settle 280 ms. Drag the puck.
drag
Carry never transitions transform — that is the classic laggy-drag bug.

Loading, progress & camera

gm-pending
Opacity 0.45→1, 1400 ms. Indeterminate work.
verifying…
Nothing that is still loading may move — motion during token streaming breaks readability.
gm-progress
Determinate width, 280 ms, precise curve.
Never animates width with a decelerating curve — progress is mechanical.
gm-camera
The signature semantic-zoom move. 450 ms, driven by variables, never by a second class.
Live pan/wheel adds gm-camera-live to drop the transition and track input exactly.

Reduced motion — the contract

Both stacks already carry a blanket transition-duration:0.001ms!important under this query, so timing is handled — everything lands instantly. What a blanket rule cannot do is remove the displacement: translateY(-3px) still moves the element, just instantly, which is a jump rather than a motion and is worse. That is the one thing the canon adds here. State stays fully visible — shadow, border, colour and progress width all still change, they just no longer travel. The binding test is your OS setting, captured in the UVP run; this page does not simulate it, because a simulation would be a second copy of the rule and would drift from it.

PrimitiveReduced-motion equivalent
gm-hover-lift / gm-hover-objectno displacement; shadow + border still respond, instantly
gm-pressno displacement; the press is still felt through :active colour
gm-focus-ringunchanged — it never moved
gm-expandopens instantly; content still reveals
gm-enter / gm-exit / gm-route-inanimation none, opacity 1 — content appears, does not travel
gm-drop-settleanimation none — the drop is instant and final
gm-pendingsteady 0.7 opacity, no pulse
gm-progresswidth still changes — it is information, not decoration
gm-camerathe jump is instantaneous
gm-draggingexempt — carry must keep tracking the pointer or dragging breaks

Token ladder

TokenValueJob
--gm-instant80 msstate flips: colour, border, focus ring
--gm-quick140 mspointer feedback: hover, press
--gm-standard280 msstructure: open/close, expand, dock, route
--gm-slow450 msspace: camera, semantic zoom, lock-in settle
--gm-ease-precisecubic-bezier(.2,0,.1,1)mechanical — the default
--gm-ease-alivecubic-bezier(.23,1,.32,1)decelerating reveal
--gm-ease-exitcubic-bezier(.4,0,1,1)accelerating leave
--gm-lift-subtle−2pxregister: controls and dense rows under ~48px (.gm-subtle)
--gm-lift−3pxregister: cards, posts, panels — the default
--gm-lift-emphatic−6pxregister: media and scene objects over ~280px (.gm-emphatic)
--gm-nudge2pxdirectional hints, list-row shift
--gm-rise6pxentrance travel
--gm-scale-up1.015grabbable objects only
--gm-scale-down0.985press
--gm-scale-pickup1.02held for drag — reads bigger than hover on purpose
--gm-shadow-lift / --gm-shadow-dragelevationpaired with lift and pickup

140 ms is the median of the two dominant legacy values (120 ms, 12 uses; 160 ms, 15 uses), so converting either way is a sub-perceptual change. Character is taste; responsiveness, target size, transform stacking, accidental layout movement and consistency across component sizes are measurable — ratification is judgment plus the adversarial tests, not a demo that looks good. Retune the values here and the whole estate follows; the architecture does not move.

Machine interface — this page has an API

This page is the executable specimen of a capability; an agent needs the same vocabulary without eyes. capability.json is generated from glee-motion.css by the same command that builds the platform mirror, so it cannot drift — ship lint blocks if it is stale, or if any primitive lacks a stated meaning. It answers the six questions a capability must answer about itself:

questionfield
What are you?what_are_you
What can I do with you?what_can_i_do — every primitive with its meaning
How do I invoke you?how_do_i_invoke — per stack, plus the magnitude registers
What happens when I do?what_happens — the resolved token values
What are your limits?limits — composition rules, the four channels that count as inventing motion, reduced-motion behaviour, how to add a primitive
How do I prove I used you correctly?how_do_i_prove_i_used_you_correctly — the specimen, the lint rule that blocks drift, and the publication verifier

It also carries "ratified", read from the canon rather than asserted — an agent learns the values are still candidate without having to trust a human's summary. Doctrine: doctrine/EXECUTABLE_CAPABILITY.md.