INDEX / 02

Motion

Three tiers, each with a different job: raw values, theme roles, and choreography. Every number below is read at build time from the token layer, the theme stylesheet and the published motion runtime — 22 motion tokens, 14 choreography tokens, 28 keyframe presets.

TIER 1
Raw values
--al-animation-{duration,timing,distance}-*

The rungs everything else is built from. Pure CSS, no opinion about when to use which. Retuned in the DTCG token sources.

19 tokens
TIER 2
Theme roles
--al-theme-animation-*-role-*

Three duration slots (fast, base, slow) and two curves (standard, emphasized), repointed by the <al-theme motion> axis. This is the layer a component reads.

5 role tokens
TIER 3
Choreography
run('grid-reveal', el)

Multi-element, multi-phase sequences a stylesheet cannot express: staggers, coordinated tracks, shared-element transitions. Reach for it only when more than one thing has to move together.

14 tokens
THE MOTION AXIS — LIVERESOLVING…

Every demo on this page runs inside one <al-theme>. Change its motion attribute here and the whole page changes with it — including the tier-1 demos. If reduced appears to stop the page working, that is the axis working.

<al-theme>
RESOLVED AT THIS ELEMENT, RIGHT NOW
role-fast
role-base
role-slow
timing-standard
timing-emphasized
WHAT THE AXIS REPOINTS7 PROPERTIES

Read from components/theme/theme.scss. Every block asserts the complete set even where a value is unchanged — custom properties inherit, so a partial block would mean "adopt whatever an outer <al-theme> decided" rather than "leave those alone". initial is not a value: it computes to the guaranteed-invalid value, so a component's var(--role, var(--legacy)) takes its fallback.

PROPERTYdefaultreducedexpressiveOS reducefull
--al-theme-animation-duration0.2s0s0.2s0.2s
--al-theme-animation-duration-long0.4s0s0.4s0.4s
--al-theme-animation-duration-role-fastroleabsent — fallback wins0s0.3sinitial
--al-theme-animation-duration-role-baseroleabsent — fallback wins0s0.6sinitial
--al-theme-animation-duration-role-slowroleabsent — fallback wins0s0.8sinitial
--al-theme-animation-timing-role-standardroleabsent — fallback winsinitialcubic-bezier(0.34,1.56,0.64,1)initial
--al-theme-animation-timing-role-emphasizedroleabsent — fallback winsinitialcubic-bezier(0.34,1.56,0.64,1)initial

This brand — altitude. No brand block redeclares any of these, so the matrix above is what altitude actually resolves — in light and in dark alike. Motion is one of the few layers a brand does not move: brands change the look, the motion axis changes the feel.

OS reduce is the media-query rule, not a value you can set: @media (prefers-reduced-motion: reduce) { :host(:not([motion='full'])) }. It is declared last so it beats expressive at equal specificity — a decorative choice never overrides the OS preference. Only an explicit motion="full", declared after it, opts back in.

TIER 2 — IN CSSNO JAVASCRIPT

Simple state transitions — hover, focus, disclosure — never need the runtime. The Sass mixin is how the library itself authors them and is not part of the published package; the CSS beside it is what a consumer writes, and needs no mixin. Either way it expands at the call site, which is what keeps it axis-aware: a --al-theme-animation-use-* token declared on :root would resolve its var() chain once, at :root, and go blind to every <al-theme motion> below it.

// Inside the library (components/*/*.scss) — the mixins are not published.
@use '../../styles/core/mixins/motion' as motion;

// Arguments: properties, role, timing. The role token is
// emitted first and its legacy fallback second, so an
// unthemed element still gets a sane value.
.al-c-thing {
  @include motion.al-motion-transition(
    background-color border-color, fast
  );
}

.al-c-panel {
  @include motion.al-motion-transition(height, slow, emphasized);
}
/* What that expands to — no mixin required. */
.thing {
  transition: transform
    var(--al-theme-animation-duration-role-base,
        var(--al-theme-animation-duration))
    var(--al-theme-animation-timing-role-standard,
        var(--al-theme-animation-timing));
}
TIER 1 — DURATION (7 STEPS)

The rungs. A component should not reach for these directly — it reads a tier-2 role token, which points at one of these and moves when the axis moves.

10.1s--al-animation-duration-1
20.2s--al-animation-duration-2
30.3s--al-animation-duration-3
40.4s--al-animation-duration-4
60.6s--al-animation-duration-6
80.8s--al-animation-duration-8
121.2s--al-animation-duration-12
TIER 1 — EASING (9 CURVES)

Every curve below runs over the same 800ms, so what differs is the shape. The plots share one scale — a control point outside the unit box is an overshoot, and spring is the only one that has one.

ease
ease--al-animation-timing-ease
linear
linear--al-animation-timing-linear
cubic-bezier
cubic-bezier(0.15,0.99,0.18,0.99)--al-animation-timing-cubic-bezier
emphasized
cubic-bezier(0.2,0,0,1)--al-animation-timing-emphasized
springOVERSHOOT
cubic-bezier(0.34,1.56,0.64,1)--al-animation-timing-spring
standard
cubic-bezier(0.45,0.05,0.25,0.95)--al-animation-timing-standard
entrance
cubic-bezier(0,0,0.2,1)--al-animation-timing-entrance
exit
cubic-bezier(0.5,0,1,1)--al-animation-timing-exit
gentle
cubic-bezier(0.76,0,0.24,1)--al-animation-timing-gentle
TIER 1 — TRAVEL (3 STEPS)

How far a thing moves. Referenced by the keyframe presets rather than baked into them, so a wrapper can retune intensity locally with style="--al-animation-distance-md: 48px" and no new preset.

sm8px--al-animation-distance-sm
md24px--al-animation-distance-md
lg64px--al-animation-distance-lg
TIER 3 — CHOREOGRAPHY (14 TOKENS)7 FAMILIES

Named sequences, run from JS. Entrances end -enter, exits -exit; discrete list sequences use -reveal / -dismiss. Each card below replays the real token against a real stage — nothing is re-implemented for the docs.

LIST
list-reveal
pattern
stagger
keyframes
rise
offset
100ms
direction
forward
max
8
list-dismiss
pattern
stagger
keyframes
sink
offset
60ms
direction
reverse
max
8
TEXT
text-reveal
Rising upfrom behindan invisible wall
pattern
stagger
keyframes
unmask
offset
80ms
direction
forward
max
8
text-reveal-words
Motionthatreadsasonesystem
pattern
stagger
keyframes
unmask
offset
45ms
direction
forward
max
40
POPOVER
popover-enter
pattern
coordinated
tracks
(root) → pop
popover-exit
pattern
coordinated
tracks
(root) → shrink
DRAWER
drawer-enter
pattern
coordinated
tracks
(root) → fade-in
drawer-exit
pattern
coordinated
tracks
(root) → fade-out
drawer-reveal
pattern
stagger
keyframes
glide-in
offset
40ms
direction
forward
max
12
drawer-dismiss
pattern
stagger
keyframes
sink
offset
35ms
direction
reverse
max
12
GRID
grid-reveal
pattern
stagger
keyframes
pop
offset
60ms
direction
center-out
max
12
MODAL
modal-enter
pattern
coordinated
tracks
[data-al-motion="scrim"] → fade-in, [data-al-motion="card"] → pop
modal-exit
pattern
coordinated
tracks
[data-al-motion="card"] → shrink, [data-al-motion="scrim"] → fade-out
DETAIL
detail-transitionDOC ONLY

document.startViewTransition captures the whole document, so replaying it here would animate this page. Set view-transition-name on [data-al-motion="thumbnail"] and [data-al-motion="detail"], then pass the DOM change as update.

pattern
shared-element
properties
transform, border-radius
TIER 3 — KEYFRAME PRESETS (28)CLICK A TILE TO PLAY IT

Named SHAPES, independent of timing — the vocabulary the choreography tokens compose. Each tile is an overflow: hidden wall around a mover, which is the structure the clip-path and mask shapes need; the rest work with or without it.

REDUCED MOTIONTWO AUTHORITIES

There are two authorities for reduced motion and they do not always agree: the <al-theme motion> axis, and the OS query itself. The OS query matters on its own because the axis only zeroes tokens on :host — content that is not wrapped in an <al-theme> gets no treatment from the token layer at all. Deciding from the OS query alone would be wrong in the other direction: it would ignore an explicit motion="full" opt-in.

So isReducedMotion() reads the tokens first — which encodes the whole cascade without duplicating a selector in JS — and only falls back to the raw OS query for content no theme governs. Note the asymmetry: a zeroed token is authoritative, but a non-zero token is not read as "motion is fine", because an unthemed element resolves to the un-zeroed :root default whatever the reader asked their OS for.

import { createCache, isReducedMotion } from '@southleft/al-web-components/motion';

// Reads the TOKENS at `el` first, which encodes theme.scss's
// whole cascade including the accessibility-first
// `:not([motion='full'])` rule, and only falls back to the raw
// OS query for content no <al-theme> governs.
if (isReducedMotion(el, createCache())) {
  el.replaceChildren(nextView);   // jump to the end state
} else {
  await run('list-reveal', el);
}
USING ITPICK THE LOWEST TIER THAT WORKS

Tiers 1 and 2 are pure CSS; reach for them for hover, focus and disclosure. Use tier 3 only when more than one element has to move in a coordinated way.

FROM A LIT COMPONENT

The reactive controller scopes motion to your host, owns its IntersectionObserver, and tears it down in hostDisconnected(). It arms a reveal with a paused, backwards-filled WAAPI animation rather than a CSS class, so there is no window in which settled content flashes before the animation starts — and nothing to un-hide if JS never runs.

import { MotionController } from '@southleft/al-web-components/controllers/motion';

export class ALThing extends ALElement {
  protected motion = new MotionController(this);

  protected firstUpdated() {
    // Arms the element paused at its first keyframe and
    // plays it on intersection: no flash of settled
    // content, and no CSS class contract to honour.
    const grid = this.renderRoot.querySelector('.grid');
    this.motion.reveal(grid, 'grid-reveal');
  }
}
FROM ANY JAVASCRIPT

run() never rejects and no-ops on the server. animatePreset() is its single-element complement, using the same timing grammar so the axis governs both identically.

import { run, animatePreset } from '@southleft/al-web-components/motion';

// A named choreography against a root.
await run('modal-exit', dialogEl);   // then tear it down

// One element, one named shape. Same timing grammar,
// so the axis governs both identically.
await animatePreset(cardEl, 'blur-up', { delay: '80ms' });
THE ONE PIECE THAT MUST LIVE IN CSS

A mask reveal needs an overflow: hidden wrapper for text to rise out from behind — WAAPI can translate the text but it cannot clip it. The wall and the mover have to be separate elements: clipping the mover itself would move the clip region along with it and reveal nothing.

<h2 class="al-c-hero__heading">
  <span class="al-c-hero__line"><span data-al-motion-line>Rising up</span></span>
  <span class="al-c-hero__line"><span data-al-motion-line>from behind a wall</span></span>
</h2>

<style lang="scss">
  .al-c-hero__heading { @include motion.al-motion-text-reveal('.al-c-hero__line'); }
</style>