SolutionsPackagesComponentsCLI — Coming soon ResourcesDocumentationStorybookChangelogBlogAbout Sign up free Get early access
Announcement

@ds-gn/motion: animation timed by your design tokens

Timelines, staggered sequences, awaitable enter and exit, viewport triggers and letter-by-letter text on the Web Animations API, with reduced motion handled for you.

@ds-gn/motion is the motion engine behind DS-GN: timelines with overlap, staggered sequences, awaitable enter and exit animations, viewport triggers, and letter-by-letter text, all on the Web Animations API. Durations and easings come from your design tokens, reduced motion is handled for you, and the core is framework-free and safe to import during server rendering.

It is on public npm today, in Public Beta, on the same 0.x version line as @ds-gn/ui and @ds-gn/tokens. This post covers what it does and how to use each part.

What you get

  • timeline() plays ordered steps on one clock, each starting a set number of milliseconds before the previous one ends.
  • sequence() staggers one preset across many elements, by order, with a cap on the total spread, or by named roles.
  • enter() and exit() return promises, reverse cleanly when interrupted, and can animate a pseudo-element such as a dialog's ::backdrop.
  • inView() starts motion once an element scrolls into view.
  • splitText() splits an element's text into characters for letter-by-letter motion, without breaking words or screen readers.
  • MotionController from @ds-gn/motion/lit scopes all of the above to a Lit host and cleans up when it disconnects.

Only transform and opacity animate, so every preset stays on the compositor.

Install

Install it with the package manager your project already uses:

npm install @ds-gn/motion
pnpm add @ds-gn/motion
bun add @ds-gn/motion

lit is an optional peer dependency, needed only for @ds-gn/motion/lit. The package does not depend on @ds-gn/tokens; it reads your token custom properties when they are present and falls back to its own values when they are not.

Presets

Every call takes a preset. Its name describes the entrance, and each preset includes the matching exit.

slideUp Rises 12px and fades in Drops 8px and fades out fade Fades in Fades out scaleIn Scales from 0.96 and fades in Scales to 0.96 and fades out slideInLeft, slideInRight, slideInTop, slideInBottom Slides in from that edge, full width or height, without fading Slides back to the same edge

Pass y to replace a preset's movement with your own vertical travel in pixels: fade with y: 4 fades in with a small rise.

Timing comes from your tokens

duration and easing accept token names, resolved at runtime from the custom properties on the animated element:

duration: 'quick' --duration-quick 180ms duration: 'moderate' --duration-moderate 240ms easing: 'emphasized' --ease-emphasized cubic-bezier(0.16, 1, 0.3, 1) easing: 'standard' --ease-standard cubic-bezier(0.2, 0, 0, 1)

A theme override from @ds-gn/tokens therefore applies automatically, including one scoped to part of the page. Entrances default to moderate and emphasized, exits to quick and standard. Raw milliseconds and any CSS easing work too.

How to

Choreograph a hero with a timeline

Each step names an element and a preset. overlap starts it that many milliseconds before the previous step ends.

import { timeline } from '@ds-gn/motion';

const heading = document.querySelector('.hero h1')!;
const asset = document.querySelector('.hero img')!;
const cta = document.querySelector('.hero .cta')!;

timeline([
  { el: heading, preset: 'slideUp', duration: 'moderate' },
  { el: asset, preset: 'fade', overlap: 200 },
  { el: cta, preset: 'scaleIn', overlap: 160 },
]);

Every call returns a playback with a finished promise, its total duration, and cancel() and finish().

Stagger a grid of cards

import { sequence } from '@ds-gn/motion';

sequence('.card', { preset: 'slideUp', stagger: 60, maxDuration: 480 });

Targets can be an element, a list, or a selector. maxDuration caps the time from the first element's start to the last element's start, shrinking the stagger so a long list does not take seconds to fill in; the last element still runs its full duration. order can be 'start', 'end', 'center' or 'random'.

For CMS-authored sections, where you do not control the markup order, pass roles and mark elements with data-motion-role. Elements are sequenced by their role's position in the list, and elements that share a role start together.

import { sequence } from '@ds-gn/motion';

const section = document.querySelector('#features')!;

sequence(section, {
  preset: 'fade',
  roles: ['heading', 'asset', 'cta'],
  stagger: 80,
});

Open and close a panel

enter() and exit() are awaitable, so you can hide an element only once its exit has finished.

import { enter, exit } from '@ds-gn/motion';

const panel = document.querySelector<HTMLElement>('#panel')!;

panel.hidden = false;
await enter(panel, 'scaleIn');

await exit(panel, 'scaleIn');
panel.hidden = true;

Calling the opposite direction mid-animation reverses from the current position, and the interrupted promise still resolves. Calling the same direction again returns the promise already in flight. cancel(panel) stops whatever is animating.

To animate a dialog's backdrop separately, pass pseudoElement:

import { exit } from '@ds-gn/motion';

const dialog = document.querySelector('dialog')!;

await Promise.all([
  exit(dialog, 'slideInLeft'),
  exit(dialog, 'fade', { pseudoElement: '::backdrop' }),
]);
dialog.close();

Start when a section scrolls into view

import { inView, sequence } from '@ds-gn/motion';

const section = document.querySelector('#features')!;

inView(
  section,
  () => sequence('.card', { preset: 'fade', stagger: 40, root: section }),
  { rootMargin: '0px 0px -10% 0px' },
);

It runs once by default; pass once: false to run on every entry. It returns a function that stops observing. Where IntersectionObserver is unavailable, the callback runs immediately, so content is never left hidden.

Animate text letter by letter

splitText() wraps each character in its own element. Words stay unbreakable, so lines still wrap between them, and assistive technology reads the text once instead of letter by letter. revert() puts the original nodes back.

import { sequence, splitText } from '@ds-gn/motion';

const eyebrow = document.querySelector('.eyebrow')!;
const split = splitText(eyebrow);
sequence(split.chars, { preset: 'fade', y: 4, stagger: 30 }).finished.then(
  split.revert,
);

Use it in a Lit component

import { html, LitElement } from 'lit';
import { MotionController } from '@ds-gn/motion/lit';

class FeatureGrid extends LitElement {
  motion = new MotionController(this);

  render() {
    return html`
      <div class="card">Tokens</div>
      <div class="card">Components</div>
      <div class="card">Motion</div>
    `;
  }

  firstUpdated() {
    this.motion.inViewSequence('.card', { preset: 'slideUp', stagger: 40 });
  }
}

customElements.define('feature-grid', FeatureGrid);

The controller resolves selectors against the host's shadow root, runs in-view motion once the host enters the viewport, and cancels everything when the host disconnects.

Reduced motion, by default

When the reader prefers reduced motion, movement is dropped, delays, staggers and overlaps collapse to 0, and everything cross-fades at the quick duration. Edge slides, which have no fade of their own, fade instead. Pass reducedMotion: 'jump' to skip straight to the end state. Promises resolve either way, so code that awaits an exit keeps working.

Mind the fold

JavaScript motion starts only after your scripts load. On server-rendered pages, keep it below the fold or on minor elements, and never fade the page's largest text. Our case study shows what happened to LCP when we did.

Versioning

@ds-gn/motion is 0.x. Patch releases do not intentionally break documented APIs, and planned breaking changes ship in a minor release with migration guidance. A change to a preset's keyframes, or to a default duration or easing, counts as a minor.

Further reading

© 2026 LightLeap Labs LLC. All rights reserved. · Terms of Service · Privacy Policy · Cookies · Contact · Support