Foundations
Motion
Every component moves on the same four durations and three curves, declared in styles.css beside the palette. Each is named for what it is used for, so the choice is
made by reading the name.
Durations
| Token | Value | For |
|---|---|---|
--duration-fast | 120ms | a state change in place: color, opacity, border, shadow, focus |
--duration-base | 200ms | movement in place (a knob, a chevron, a collapse) and a small surface arriving (menu, popover, tooltip, toast) |
--duration-slow | 320ms | a large surface arriving (dialog, drawer) |
--duration-progress | 500ms | a value moving (a progress bar's width) |
Curves
| Token | Value | For |
|---|---|---|
--ease-enter | cubic-bezier(0.22, 1, 0.36, 1) | arriving: starts fast and settles |
--ease-exit | cubic-bezier(0.32, 0, 0.67, 0) | leaving: starts slow and accelerates away |
--ease-move | cubic-bezier(0.65, 0, 0.35, 1) | changing in place |
One rule for exits
An exit runs one rung faster than its entrance, on the exit curve: slow in, base out. Base in,
fast out. Entrances and exits move only opacity and transform. Nothing
here needs deciding per component. The presets below derive the exit from the entrance.
In a class attribute
The transition-* utilities default to the fast rung and the move curve, so transition-colors on its own is complete, and so is transition-shadow for an elevation change. For the other rungs and curves the kit
ships duration-base, duration-slow, duration-progress, ease-enter, ease-exit and ease-move.
A disclosure animates the one property that opens it. Downwards that is grid-template-rows, the collapsible pattern. Sideways it is width, and
it takes the same rung and curve, which is what the app shell's sidebar does between its rail and
its expanded column. Nothing else animates layout.
<button class="transition-colors hover:bg-surface-2">Save</button>
<svg class="transition-transform duration-base {open ? 'rotate-180' : ''}">...</svg>
<aside class="transition-[width] duration-base ease-move {rail ? 'w-nav-rail' : 'w-sidebar'}"></aside>
<div class="h-full transition-[width] duration-progress" style="width: {pct}%"></div>Do not write a numbered duration such as duration-150, an arbitrary one such as duration-[250ms], the curves ease-in, ease-out, ease-in-out and ease-linear, or transition-all. A duration
of your own is a fifth speed nobody chose, those curves are ones the theme never states, and transition-all animates whatever happens to change, layout included.
A surface that mounts and unmounts
A surface inside {#if} enters and leaves through the motion presets,
the same ones the kit's own overlays use. They read the tokens at run time, so retuning a token
retunes them, and they play nothing for a reader who has asked for reduced motion. Keep the |global modifier: a Svelte transition is local by default and would not play when a parent
block removes the surface.
Never wait for an animation with a timer. A setTimeout that holds an element until its
exit has played is the duration written a second time, and it drifts the day the token changes. Remove
the element and let its outro play.
<script>
import { motion } from '@lyeve-labs/ui-kit';
</script>
{#if open}
<div transition:motion.popover|global>...</div>
{/if}
{#each items as item (item.id)}
<li transition:motion.toast|global animate:motion.reorder>...</li>
{/each}| Preset | In | Out | Frame |
|---|---|---|---|
dialog | slow, enter | base, exit | fades, rises 8px, scales from 0.96 |
scrim | slow, enter | base, exit | fades |
drawer | slow, enter | base, exit | slides the whole panel in from its edge. Takes a side |
popover | base, enter | fast, exit | fades, drops 4px, scales from 0.96 |
toast | base, enter | fast, exit | fades, rises 8px, scales from 0.96 |
reorder | base, move | For animate:. Siblings close the gap a removed
item leaves. | |
A change of page
A page is a parent block too, so every surface on it plays its exit when the reader leaves the
page, and Svelte keeps the page on screen until the last exit ends, with the new page below it.
Call motion.swapPage() just before the router replaces the page. Until the next frame every
preset runs in no time, so the old page goes and the new one arrives in the same frame.
<script>
import { onNavigate } from '$app/navigation';
import { motion } from '@lyeve-labs/ui-kit';
onNavigate((navigation) => {
if (navigation.from?.url.pathname !== navigation.to?.url.pathname) motion.swapPage();
});
</script>Reduced motion
A reader who has asked for reduced motion gets every state change and none of the travel. The
stylesheet shortens every CSS transition and animation to 1ms, so a component that
waits on an animationend still hears it, and the presets return a zero duration, so a surface
mounts and unmounts at once.
That one rule in the kit's stylesheet is the only prefers-reduced-motion block. A component
or a page never writes a second one: the global rule already covers it, and a second block is a place
where the two can disagree.