Skip to content
Documentation/Angular patterns

Search documentation

Type to search the documentation.

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.

product-card.ts
@Component({
  selector: 'app-product-card',
  imports: [MoveInViewDirective, MoveHoverDirective],
  template: `<article moveInView="fade-up" moveWhileHover="lift">…</article>`,
})
export class ProductCard {}

View source

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().

buttons.ts
@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 {}

View source

Staggered grid

grid.ts
@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' }]);
}

View source

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.

toast-dismiss.ts
@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 source

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.

timing.ts
@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' })],
};

View source

Reduced motion

Three layers, strongest first; the OS preference always wins. The full explanation is on Accessibility and troubleshooting.

first-render.ts
@Component({
  selector: 'app-first-render',
  imports: [MoveEnterDirective],
  template: `<div moveEnter="fade-up" [moveDisabled]="isFirstRender()">…</div>`,
})
export class FirstRender {
  protected readonly isFirstRender = signal(true);
}

View source

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.

passthrough.ts
@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 {}

View source

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:

sequence.ts
@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
  }
}

View source

@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.

list.ts
@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' }]);
}

View source

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.

save-button.ts
@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 {}

View source