@austencloud/motion
v0.1.3
Published
One motion clock for AustenCloud apps: duration tokens, reduced-motion-aware Svelte transitions, Crossfade, followHeight, view-transition page changes with record morphs, and a coverage scanner that keeps raw motion out.
Maintainers
Readme
@austencloud/motion
One motion clock for AustenCloud apps. It holds the duration tokens, Svelte transitions that honor reduced motion, a Crossfade component, followHeight, page changes built on the View Transitions API with record morphs (a clicked box grows into its page), and a scanner that fails a test when an app writes raw motion outside these owners. Ringmaster, the Cirque Aflame website and the Flow Arts Composer share it so every layout change moves on the same clock.
Install
npm install @austencloud/motionSvelte 5 is a peer dependency. SvelteKit is not required; the page-change helper takes SvelteKit's onNavigate as an argument, so the package never imports $app/*.
Owners
Decide first. Movement that means nothing (a summary changing length, a count updating) gets reserved space so nothing moves. Movement that means something (a row arriving, a panel swapping, a record opening) is animated by one of these owners.
| Change | Owner |
| --- | --- |
| Two states swap in one box | Crossfade (fill for sized parents, animateHeight when heights differ a lot) |
| One element enters or leaves flow | growFade |
| A badge or control in reserved space | flyFade, popIn |
| Keyed list filters, sorts or reflows | animate:flip={{ duration: flipDuration() }} with in:arrive={{ index }} and out:leave; the list gets position: relative |
| A box follows its content's height | followHeight |
| A list-detail panel swap | an app-level layout built on Crossfade |
| Opening a record page | the route morph: the source box has data-record="kind:id", the page header has data-record-target="kind:id", the photo has data-record-photo, and a page still loading calls holdPageReady() |
Clock
Durations come from DURATION (instant 100, fast 150, normal 200, emphasis 280, dramatic 350, scene 800) or the matching CSS tokens (--duration-fast, --duration-normal, --duration-emphasis, --duration-dramatic) with --ease-standard. Feature code never writes raw milliseconds, raw cubic-bezier values or transition: all.
import { DURATION, STAGGER, flyFade, growFade, popIn, Crossfade, followHeight } from '@austencloud/motion';Page changes
Call installRouteMotion once from the root layout's script, during component init. SvelteKit's onNavigate requires that.
<script lang="ts">
import { onNavigate } from '$app/navigation';
import { installRouteMotion } from '@austencloud/motion';
// Route ids of pages that show one record, and the params naming it.
const RECORD_ROUTES = {
'/performers/[id]': { kind: 'performer', params: ['id'] },
'/services/[category]/[service]': { kind: 'service', params: ['category', 'service'] }
};
installRouteMotion({ routes: RECORD_ROUTES, onNavigate });
</script>Mark up the page with these attributes:
data-route-contenton the page area that fades between pages.data-route-holdon chrome that overlaps the page area (a fixed header, a phone bottom bar), so it keeps its own layer above the page. It takes exactly two values,headerandnav; any other value does nothing.data-record="kind:id"on the box that opens a record. Several route params are joined with/, so the service page above isservice:fire/fire-show.data-record-target="kind:id"on the header of the page that shows that record.data-record-photoon a photo that should travel with the box. It draws above the box. Writedata-record-photo="backdrop"on a banner photo that has a scrim and a headline on it: the box's layer then stays above the photo on the way in and back, the photo is cropped to its moving box, and the header must leave its background clear behind the photo so the photo shows through.holdPageReady()in a page that is still loading when navigation ends. It returns a release function; call it once the header exists.
A clicked box morphs into its record page. Going back to the page that box was on morphs the header back into it; any other page change from a record page, such as a booking link to a form, fades, because a header with no box to land in would lift its photo above its own headline. The leaving page fades where it was on screen, even when the new page opens at a different scroll. A query-only change on the same page is that page's own motion and gets none here. Reduced motion and browsers without view transitions get the plain swap.
CSS
@import '@austencloud/motion/css/tokens.css';
@import '@austencloud/motion/css/route-transitions.css';or, from a layout script, import '@austencloud/motion/css/tokens.css' and import '@austencloud/motion/css/route-transitions.css'. tokens.css defines the --duration-*, --ease-standard* and --stagger-* custom properties. route-transitions.css holds the ::view-transition-* rules and keyframes keyed on the attributes above. The token values are tested against DURATION and STAGGER.
route-transitions.css reads those tokens through var(--duration-*) and var(--ease-standard*). Load tokens.css too, or define the same tokens in the app's own CSS. Without them the animation declarations are invalid at computed-value time and page transitions silently drop to no animation.
Coverage scanner
Node-only, so it lives on its own subpath. Each app runs it over its src/ in a test and lists its own exemptions.
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, it } from 'vitest';
import { scanTree } from '@austencloud/motion/coverage';
const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const NOT_YET = new Set<string>(); // files still waiting for an owner
const skip = (file: string) => NOT_YET.has(file) || file.startsWith('lib/motion/');
it('has no raw motion anywhere in the app', () => {
const findings = scanTree(SRC, skip);
expect(findings.map((f) => `${f.file}: ${f.rule}: ${f.text}`)).toEqual([]);
});It flags svelte/transition imports, transition: all, raw millisecond durations in CSS, directives and motionDuration(...), and raw cubic-bezier values.
Always
- Honor reduced motion. The owners already do; a hand-written transition goes through
motionDuration. - A second change mid-animation continues from what is on screen.
- Keep keyboard focus, scroll position and immediate interaction during transitions.
- A structural motion change is not done until its frames have been watched in a real browser.
Releasing
0.1.0 is published by hand. Later versions go through changesets like the rest of the shared-packages repo: run pnpm changeset in the repo root, merge to main, and the Release workflow publishes.
