| name | material-motion |
| description | Apply Material 3 motion transitions (container transform, shared axis, fade through, fade) consistently in the claims forms app. Use when adding or reviewing a transition between screens, steps, or UI states -- picking the right M3 pattern and executing it with GSAP on the project's motion tokens. Triggers on "add a page transition", "make this feel like Material", "animate the navigation", "apply M3 motion". |
| metadata | {"author":"insclix","version":"1.0.0"} |
Material Motion (M3 transitions)
Material 3 motion is not "add a fade." It is: identify the relationship
between the two UI states, pick the transition pattern that expresses that
relationship, then build it with GSAP reusing the project's motion tokens.
Spec: https://m3.material.io/styles/motion/transitions
This skill is the design-intent layer -- WHICH pattern, WHEN. For the GSAP
mechanics (lifecycle, SSR, cleanup, FLIP) defer to the gsap-animation skill.
For the token vocabulary, read $lib/claim-form/motion.ts. Never invent
durations or eases -- that is what keeps every animation in the app feeling
like one system.
Step 1 -- pick the pattern by the relationship
| Relationship between the two states | M3 pattern | App example |
|---|
| Sequential siblings -- wizard next/back, tabs, carousel | Shared axis X (horizontal slide + fade) | The claim step wizard (already built -- the canonical example below) |
| Hierarchy up/down -- a list and its detail with a vertical relationship | Shared axis Y (vertical slide + fade) | Drilling a settings row into its panel |
| Parent into child / zoom into depth | Shared axis Z (scale + fade) | Opening an item "into" a focused view |
| One element becomes another (a card grows into a full screen, a list row into its detail page) | Container transform (FLIP) | A claims-list row morphing into the claim detail; a choice card into the form |
| Swapping unrelated content in the same container (no spatial or hierarchical link) | Fade through (fade + subtle scale, sequential) | AuthScreen <-> ChoiceScreen; switching between unrelated panels |
| An element entering or leaving the screen bounds | Fade | Dialogs, menus, snackbars, toasts |
If two states have no clear relationship, it is fade through, not shared
axis. If one literally turns into the other, it is container transform, not
a slide. Picking the wrong pattern is the most common mistake -- the slide of a
shared axis implies a spatial relationship that fade-through content does not
have.
Step 2 -- easing and duration (reuse the tokens)
The project standardizes on one easing family and three durations. M3's
"standard easing" maps cleanly onto them; we deliberately do not introduce
M3's emphasized curves or longer durations, so all motion stays uniform.
- Enter / settle into place:
GSAP_EASE ("power2.out" = the project's cubicOut)
- Exit / accelerate off-screen:
GSAP_EASE_IN ("power2.in")
- Durations from
MOTION_S (seconds): M3 "short" -> MOTION_S.fast (0.15),
"medium" -> MOTION_S.base (0.22) / MOTION_S.slow (0.32). Do not add longer
durations.
- Slide distance: ~28px (small offset + fade reads as M3, not a full-width swipe).
- Stagger a list of children with
gsapStagger(n) -- 0.04s each, capped so
long lists stay snappy.
import { MOTION_S, GSAP_EASE, GSAP_EASE_IN, gsapStagger, prefersReducedMotion } from "$lib/claim-form/motion";
Step 3 -- recipes
All of these are token-driven. Keep one orchestrated gsap.timeline() per
transition rather than scattered tweens.
Shared axis X (the canonical example)
The claim step wizard is the reference. The shape: keep ONE persistent pane
element and a lagging "displayed" state, so the outgoing content stays on
screen while it slides out; then swap the content and slide the incoming in;
then stagger the freshly-rendered cards. direction is +1 forward / -1 back and
flips the slide sign.
const tl = gsap.timeline({ defaults: { ease: GSAP_EASE } });
tl.to(pane, { opacity: 0, x: -dir * 28, duration: MOTION_S.base * 0.7, ease: GSAP_EASE_IN })
.add(() => { displayedIndex = target; })
.set(pane, { x: dir * 28 })
.to(pane, { opacity: 1, x: 0, duration: MOTION_S.slow })
.add(revealChildren, "<+=0.05");
See svelte/src/lib/claim-form/ClaimFormPage.svelte -> runStepTransition() and
revealSections() for the full, production version (direction tracking,
reduced-motion guard, cleanup). Clip the slide's horizontal overflow on the
viewport so it never spawns a scrollbar.
Shared axis Y / Z
Same skeleton as X. For Y, animate y instead of x. For Z (depth),
drop the translate and animate scale (incoming 0.92 -> 1, outgoing 1 -> 1.08)
plus opacity.
Fade through (unrelated content)
Sequential, no slide -- the outgoing content fully fades before the incoming
arrives, so they never cross-dissolve into mush.
const tl = gsap.timeline({ defaults: { ease: GSAP_EASE } });
tl.to(outgoing, { opacity: 0, scale: 0.92, duration: MOTION_S.fast, ease: GSAP_EASE_IN })
.add(() => { swapContent(); })
.from(incoming, { opacity: 0, scale: 0.92, duration: MOTION_S.base });
Container transform (one element becomes another)
Use the GSAP Flip plugin for the shared-element morph -- record the start
state, mutate the DOM to the end layout, then Flip.from(). Do not fake it with
absolute positioning.
import { Flip } from "gsap/Flip";
gsap.registerPlugin(Flip);
const state = Flip.getState(sharedEl);
Flip.from(state, { duration: MOTION_S.slow, ease: GSAP_EASE, absolute: true });
Fade (entering/leaving the screen)
Prefer the existing Svelte fade/component transitions for dialogs, menus, and
overlays (e.g. the submit/progress dialogs). Reach for GSAP only when the fade
is part of a longer sequence.
Reduced motion is mandatory
Every transition must collapse to an instant swap under
prefers-reduced-motion. Guard at the top -- snap the displayed state, skip the
timeline:
if (prefersReducedMotion()) { swapContent(); return; }
Lifecycle / SSR
Build timelines in onMount, kill them in onDestroy (tl?.kill()), and never
run GSAP at module top level (SSR has no window). See the gsap-animation
skill for the full lifecycle/cleanup guidance.
Don't
- Don't mix two patterns in one swap (e.g. slide AND morph) -- pick one.
- Don't use container transform for full-page step swaps -- that is shared axis X.
- Don't invent durations or eases -- reuse
MOTION_S / GSAP_EASE.
- No bouncy/overshooting eases (
back, elastic, bounce) -- the project bans them.
- Don't cross-dissolve unrelated content -- fade through is sequential.
Canonical reference
svelte/src/lib/claim-form/ClaimFormPage.svelte -- runStepTransition() /
revealSections(): the shared axis X timeline + staggered card reveal.
svelte/src/lib/claim-form/StepRenderer.svelte -- data-step-section wrappers
the timeline targets for the stagger.
svelte/src/lib/claim-form/motion.ts -- MOTION_S, GSAP_EASE,
GSAP_EASE_IN, gsapStagger(): the token source every recipe reads from.