Concepts
Angular patterns
The patterns real apps reach for most — hover and press feedback, reveal-on-scroll, staggered lists, imperative animation — and how Movement behaves alongside the Angular features you already use.
Where there is a common mistake, it is stated first.
Common patterns
Product card
Reveal once when scrolled into view, lift on hover. Import the two directives the component uses.
@Component({
selector: 'app-product-card',
imports: [MoveInViewDirective, MoveHoverDirective],
template: `<article moveInView="fade-up" moveWhileHover="lift">…</article>`,
})
export class ProductCard {}
Button interaction
lift and press are the two interaction presets. Hover only responds to a mouse or pen — a finger has no hover state — so pair it with moveWhileTap for touch feedback. Neither directive calls preventDefault().
@Component({
selector: 'app-buttons',
imports: [MoveHoverDirective, MoveTapDirective, RouterLink],
template: `
<button type="button" moveWhileHover="lift" moveWhileTap="press">Buy now</button>
<!-- Links too: touch taps and scrolls behave natively. -->
<a routerLink="/pricing" moveWhileHover="lift" moveWhileTap="press">Pricing</a>
`,
})
export class Buttons {}
Staggered grid
@Component({
selector: 'app-grid',
imports: [MoveStaggerDirective, MoveInViewDirective],
template: `
<ul moveStagger moveStaggerStep="80ms">
@for (item of items(); track item.id) {
<li moveInView="fade-up">{{ item.label }}</li>
}
</ul>
`,
})
export class Grid {
protected readonly items = signal([{ id: 1, label: 'One' }]);
}
Imperative animation
MoveAnimator is the TypeScript API. It takes an Element or the ElementRef from viewChild(), and adds set() / clear() for committing or removing a state without hand-writing styles.
@Component({
selector: 'app-toast-dismiss',
template: `
<div #toast>Saved</div>
<button type="button" (click)="dismiss()">Dismiss</button>
`,
})
export class ToastDismiss {
readonly #animator = inject(MoveAnimator);
protected readonly toast = viewChild.required<ElementRef<HTMLElement>>('toast');
async dismiss(): Promise<void> {
// `finished` always resolves: natural finish, cancel(), a second play(), or teardown.
await this.#animator.animate(
this.toast(),
{ opacity: [1, 0], y: [0, 12] },
{ duration: '180ms' },
)?.finished;
this.toast().nativeElement.remove();
}
}
View Transition
pseudoElement animates ::view-transition-new(root) (or any pseudo-element) through the same API. Unsupported browsers get a no-op, and under reduced motion the pseudo-element animation is skipped while your DOM change still applies. See the live demo.
const transition = document.startViewTransition(() => commitTheme());
await transition.ready;
animator.animate(
document.documentElement,
{ clipPath: ['circle(0px at 40px 40px)', 'circle(1500px at 40px 40px)'] },
{ duration: '520ms', pseudoElement: '::view-transition-new(root)' },
);
Timing units
Every duration, delay and stagger accepts a MoveTime: a number is milliseconds, and a string can say "80ms" or "0.08s". On static attributes, write the unit — it reads unambiguously.
@Component({
selector: 'app-timing',
imports: [MoveStaggerDirective],
template: `
<!-- Numbers are always milliseconds. Strings say their unit. -->
<ul moveStagger moveStaggerStep="80ms"></ul>
<ul moveStagger moveStaggerStep="0.08s"></ul>
<ul moveStagger [moveStaggerStep]="80"></ul>
`,
})
export class Timing {}
export const timingConfig: ApplicationConfig = {
providers: [provideMovement({ duration: '320ms' })],
};
Reduced motion
Three layers, strongest first; the OS preference always wins. The full explanation is on Accessibility and troubleshooting.
@Component({
selector: 'app-first-render',
imports: [MoveEnterDirective],
template: `<div moveEnter="fade-up" [moveDisabled]="isFirstRender()">…</div>`,
})
export class FirstRender {
protected readonly isFirstRender = signal(true);
}
When CSS is the better tool
Angular Movement runs after hydration, so it cannot animate the first paint. For an above-the-fold entrance that must be in motion before the app boots, use a CSS animation. Use the library for everything that happens once the app is running: interaction, reveal-on-scroll, presence, state changes, imperative sequences.
/* Above-the-fold entrance that must exist before hydration → CSS. */
.hero {
animation: hero-in 600ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes hero-in {
from {
opacity: 0;
translate: 0 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.hero {
animation: none;
}
}
Any CSS property
Keyframes are typed for the common shorthands (x, y, scale, blur, …), and any other property passes straight through to the Web Animations API.
@Component({
selector: 'app-passthrough',
imports: [MoveEnterDirective, MoveHoverDirective],
template: `
<div [moveEnter]="{ clipPath: ['inset(0 100% 0 0)', 'inset(0 0% 0 0)'] }">Wipe</div>
<img alt="" src="/photo.jpg" [moveWhileHover]="{ filter: ['saturate(0.6)', 'saturate(1)'] }" />
`,
})
export class Passthrough {}
Awaiting and cancellation
Unlike raw WAAPI, whose finished rejects on cancel, Angular Movement's finished (and moveTrigger.play()) always resolve — on natural finish, cancel(), a second play() replacing the first, the owner being destroyed, or a skipped View Transition. It never rejects, so no try/catch is needed. It does not say how the animation ended; when that matters, track it yourself:
@Component({
selector: 'app-sequence',
template: `<div #panel>Panel</div>`,
})
export class Sequence {
readonly #animator = inject(MoveAnimator);
protected readonly panel = viewChild.required<ElementRef<HTMLElement>>('panel');
#run = 0;
async expand(): Promise<void> {
// `finished` always resolves: natural finish, cancel(), a second play() replacing the first.
const run = ++this.#run;
await this.#animator.animate(this.panel(), { height: ['0px', '120px'] }, { duration: '420ms' })
?.finished;
if (run !== this.#run) return; // a newer run took over
}
}
@if and leave animations
This is the single most common surprise. @if destroys the view synchronously, so by the time a leave animation could run, the element is already gone. The directive is not broken — there is simply no node left.
@if (isOpen()) {
<div [moveLeave]="{ opacity: [1, 0] }">Panel</div>
}
<ng-container *movePresence="isOpen()">
<div [moveEnter]="{ opacity: [0, 1] }" [moveLeave]="{ opacity: [1, 0] }">Panel</div>
</ng-container>
*movePresence holds the view in the DOM, waits for every registered child's leave animation, and only then removes it. See Presence.
@for and stagger
moveStagger derives each child's delay from its position in the DOM, so it works with @for without any manual index bookkeeping.
@Component({
selector: 'app-stagger-for',
imports: [MoveStaggerDirective, MoveEnterDirective],
template: `
<ul moveStagger moveStaggerStep="60ms" moveStaggerDirection="first">
@for (item of items(); track item.id) {
<li [moveEnter]="{ opacity: [0, 1], y: [16, 0] }">{{ item.label }}</li>
}
</ul>
`,
})
export class StaggerFor {
protected readonly items = signal([{ id: 1, label: 'One' }]);
}
Your track expression matters more than it looks:
// ✗ track by index: Angular reuses DOM nodes, so reordering does not re-run
// enter animations and moveLayout sees no position change.
@for (item of items(); track $index) { … }
// ✓ track by a stable identity
@for (item of items(); track item.id) { … }
Server-side rendering
Every directive is a no-op on the server and starts after hydration. There is no configuration and no @defer needed — the content is in the SSR payload, which is what you want for SEO. If you write your own motion around the library, guard it the same way:
import { isPlatformBrowser } from '@angular/common';
import { inject, PLATFORM_ID } from '@angular/core';
const isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
Avoid hiding content in CSS and relying on an animation to reveal it: the server renders that state, and users with reduced motion never see it change.
<div [moveEnter]="{ opacity: [0, 1] }">Visible in the SSR payload</div>
Standalone imports
There is no NgModule. Import the directives a component uses — usually one to three. MOVEMENT_DIRECTIVES still works, but it pulls in every directive (experimental ones included) and hides the component's real dependencies; MOVEMENT_STABLE_DIRECTIVES is the stability-pure aggregate.
@Component({
selector: 'app-save-button',
imports: [MoveHoverDirective, MoveTapDirective], // exactly what the template uses
template: `<button type="button" moveWhileHover="lift" moveWhileTap="press">Save</button>`,
})
export class SaveButton {}