Skip to content
Docs · 11 pages

Docs / Style

Motion tokens and presets

Five sampled springs, one attribute to switch the whole page, and the durations, travel and stagger every component resolves from, as CSS custom properties and an opt-in Tailwind v4 layer.

2 min readThis page as MarkdownSource on GitHub

No component hard-codes a curve, a duration, a travel distance or a stagger. Everything resolves from six tokens on :root, and those six are filled by the active preset:

Preset Spring (stiffness / damping) Settles in Feel
snap 1218 / 70 290 ms clicks into place
ui (default) 305 / 33 450 ms product UI
gentle 110 / 20 760 ms editorial
lively 622 / 17 840 ms overshoots, bounces back
ambient 43 / 13 1390 ms slow, atmospheric

Each spring is a real mass–spring–damper simulation sampled into a CSS linear() function, with the time it takes to settle within 0.1% as the duration. That is a spring without a physics runtime: the curve is a timing function, the browser interpolates it, and it costs nothing per frame.

Switch the page

<html data-ma-preset="lively">

Any subtree can carry its own: <section data-ma-preset="snap">. The attribute sets these six tokens; components read them:

--ma-ease            /* the sampled spring */
--ma-duration        /* its settle time */
--ma-stagger         /* 40 / 60 / 120 ms */
--ma-travel-enter    /* 16–48 px, how far an entrance travels */
--ma-travel-hover    /* 2–6 px */
--ma-travel-section  /* 48 px, page-level motion */

The homepage’s preset switcher is five radio inputs and a :root:has() rule per preset: the same tokens, switched with zero JavaScript.

Fixed curves

Exits and continuous motion never use a spring:

Token Value Use
--ma-ease-out cubic-bezier(0.23, 1, 0.32, 1) exits, hover release
--ma-ease-in cubic-bezier(0.5, 0, 0.75, 0) things leaving
--ma-ease-in-out cubic-bezier(0.77, 0, 0.175, 1) ambient loops
--ma-ease-drawer cubic-bezier(0.32, 0.72, 0, 1) sheets
--ma-duration-fast 150ms exits, hover, press
--ma-duration-slow 700ms long reveals

Override any of them in your own CSS: :root { --ma-ease: … }. The base stylesheet has zero specificity, so yours wins. A prop on a component (<Reveal duration={900}>) sets a component-local property that wins over the preset for that instance only.

Tailwind v4

@import 'tailwindcss';
@import '@moonarc/core/styles';

That adds ease-ma / duration-ma (the active preset), ease-snap … ease-ambient and duration-snap … duration-ambient (the presets by name), plus ease-out-quint, ease-in-quint, ease-in-out-quint, ease-drawer, duration-fast, duration-base, duration-slow as real utilities mapped onto the same custom properties.

Tailwind is never required. Components ship their own layered CSS; this file is the only Tailwind-specific thing in the package, and it is opt-in.

Cascade layers

The base stylesheet declares @layer theme, base, components, utilities and puts component rules in components. Tailwind v4 puts utilities in utilities, so class="p-0" on a component always wins. Without Tailwind, any unlayered rule of yours beats the library. Either way you never fight specificity.

The flip side: a reset of your own that is unlayered beats the library too. A hand-written a { color: inherit } outside any layer recolours every link a component draws, including a primary button’s label, which then sits on the primary colour in your body text colour. Put resets in @layer base, where Tailwind’s preflight already is.