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.
--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.
--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.
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.
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.
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.
This brand — southleft. No brand block redeclares any of these, so the matrix above is what southleft 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.
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));
}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.
--al-animation-duration-1--al-animation-duration-2--al-animation-duration-3--al-animation-duration-4--al-animation-duration-6--al-animation-duration-8--al-animation-duration-12Every 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--al-animation-timing-easelinear--al-animation-timing-linearcubic-bezier(0.15,0.99,0.18,0.99)--al-animation-timing-cubic-beziercubic-bezier(0.2,0,0,1)--al-animation-timing-emphasizedcubic-bezier(0.34,1.56,0.64,1)--al-animation-timing-springcubic-bezier(0.45,0.05,0.25,0.95)--al-animation-timing-standardcubic-bezier(0,0,0.2,1)--al-animation-timing-entrancecubic-bezier(0.5,0,1,1)--al-animation-timing-exitcubic-bezier(0.76,0,0.24,1)--al-animation-timing-gentleHow 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.
--al-animation-distance-sm--al-animation-distance-md--al-animation-distance-lgNamed 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-reveallist-dismisstext-revealtext-reveal-wordspopover-enterpopover-exitdrawer-enterdrawer-exitdrawer-revealdrawer-dismissgrid-revealmodal-entermodal-exitdetail-transitionDOC ONLYdocument.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.
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.
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);
}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.
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');
}
}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' });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>