@ds-gn/motion gives DS-GN projects timelines, sequencing, and enter/exit
motion on the Web Animations API. Durations and easings come from the motion
tokens, every call respects prefers-reduced-motion, and the core has no
framework dependency, so it works in Lit, React, and plain pages alike.
npm install @ds-gn/motion
Importing either entry point never touches window or document, so it is
safe in server-rendered code. Motion only runs when you call it in the browser.
What it does
| Page | For |
|---|---|
| Timeline | Ordered steps that start before the previous one ends |
| Sequence | One preset across many elements, or letter by letter |
| Enter and exit | Awaitable entrances and exits, and viewport triggers |
| Lit | MotionController, scoped to a Lit host |
The rest of this page covers where to use motion and what every call shares: presets, tokens, reduced motion, and the handle that timelines and sequences return.
Where to use motion
Motion runs in JavaScript, so it starts once your scripts load, and on a server-rendered page that is after the content has already painted. Use it where that does not matter:
- Below the fold. Start entrances with an in-view trigger
(
inViewor the Lit controller’sinViewSequence), so they play as each section scrolls into view rather than on load. If a section is already on screen when your scripts load, leave it static rather than hide it and animate it back. If it is off screen, you can hide its parts from script right then, so a jump to it (an in-page link) shows no unanimated frame before the entrance starts. - Minor elements above the fold, such as an eyebrow or a badge, and never the page’s largest text or image. Largest Contentful Paint does not count an element while it is at opacity 0, so animating it in moves the metric to whenever the animation reveals it.
DS-GN components that animate expose a motion attribute with values of their
own: sections such as Hero and FeatureTabs take motion="on" or "off"
(default "off"), and Modal takes the direction it animates from.
Keep everything else above the fold static. If a minor above-the-fold element
must stay hidden until its entrance, hide it only until its custom element
upgrades (for example with :not(:defined) in your stylesheet), only when the
user has not asked for reduced motion, and reveal it under <noscript>, so it
cannot stay hidden when JavaScript is off. A script that fails to load leaves
such an element hidden, so reserve the pattern for decoration.
Presets
| Preset | Enter | Exit |
|---|---|---|
slideUp | y 12px → 0, opacity 0 → 1 | y 0 → 8px, opacity 1 → 0 |
fade | opacity 0 → 1 | opacity 1 → 0 |
scaleIn | scale 0.96 → 1, opacity 0 → 1 | scale 1 → 0.96, opacity 1 → 0 |
The slideInLeft, slideInRight, slideInTop, and slideInBottom presets
move a sheet from/to its full width or height (translateX/Y(±100%)) without
fading. Under reduced motion they substitute a fade, so no empty keyframes or
spatial movement remain. Preset names describe the entrance; each includes
its matching exit, so exit(panel, 'slideInLeft') slides back to the left edge,
just as exit(panel, 'scaleIn') uses the scale preset’s exit.
Only transform and opacity animate, so presets stay on the compositor.
Every call takes a y option, in pixels, that replaces a preset’s movement
with vertical travel: an entrance starts y below its resting place (negative
values start above), and an exit ends there. enter(el, 'fade', { y: 4 })
fades in with a small rise. Like all movement, y is dropped under reduced
motion.
An entrance ends on the element’s own resting style. Make sure that style is
visible: an element hidden with opacity: 0 in CSS fades in and then
disappears again. Below the fold, leave the element visible in CSS instead of
hiding it until its trigger: an entrance holds its first frame from the moment
it starts, and an in-view trigger fires as soon as the element reaches the
viewport, so at most a sliver at the edge shows before the entrance takes over.
Hiding it in CSS would keep it hidden for anyone whose scripts never run.
Tokens
Durations and easings are named after the motion tokens and resolve from custom properties on the animated element when the animation starts:
| Name | Custom property | Fallback |
|---|---|---|
'quick' | --duration-quick | 180ms |
'moderate' | --duration-moderate | 240ms |
'emphasized' | --ease-emphasized | cubic-bezier(0.16, 1, 0.3, 1) |
'standard' | --ease-standard | cubic-bezier(0.2, 0, 0, 1) |
So a token override from @ds-gn/tokens, including one set on a single
section, changes motion with no code changes. Raw milliseconds and any CSS
easing work too: duration: 320, easing: 'linear'.
Reduced motion
When prefers-reduced-motion: reduce is set:
- translate and scale are dropped;
- delays, staggers, and overlaps collapse to 0;
- everything cross-fades at
duration.quick, or, withreducedMotion: 'jump', goes straight to the end state; - promises still resolve, so code awaiting an exit never hangs.
prefersReducedMotion() is exported for timer-driven animation that needs the
same gate.
Playback
timeline and sequence return a Playback:
| Member | Description |
|---|---|
finished | Promise that resolves when everything finishes or is cancelled |
duration | Total scheduled length in ms |
finish() | Jump every animation to its end state |
cancel() | Remove the animations’ effects; elements return to their own CSS |
finished never rejects.