Angular Movement

September 25, 2026 Β· View on GitHub

Angular Movement logo

Angular Movement

Animate Angular with a single attribute.

Declarative, signals-native motion for Angular 21 and 22 β€” presets, variants, gestures, spring physics, drag, scroll-linked animation, layout transitions, presence orchestration, motion values & SVG path-drawing. SSR-safe, zoneless-ready. Zero @angular/animations.


CI npm version npm downloads bundle size Angular License: MIT PRs welcome

🌐 Live Demo Β Β·Β  πŸ“š Docs Β Β·Β  πŸ“¦ npm Β Β·Β  πŸ—ΊοΈ Roadmap

Angular Movement β€” animate Angular with a single attribute

Why Angular Movement?

UI animation in Angular tends to sprawl: enter/leave transitions rewritten per component, imperative logic tangled into templates, inconsistent timings across a team, and no clean way to orchestrate staggered lists or exit animations.

Angular Movement replaces that boilerplate with declarative directives and one global config, so motion stays consistent, composable, and SSR-safe. Playback runs on the browser's native Web Animations API (with an optional spring physics engine) β€” no @angular/animations setup required.

<h2 [move]="'fade-up'">Hello movement</h2>
<button [moveWhileHover]="{ scale: [1, 1.05] }">Hover me</button>

✨ Features

🎬 30+ presetsfade, slide, zoom, flip, blur, bounce, pulse, spin, icon-draw/pulse/bounce
🧬 Custom keyframesfull control when a preset isn't enough; repeat / repeatType / repeatDelay
πŸƒ Spring physicspre-computed spring keyframes via a dedicated engine
πŸ–±οΈ Interactionshover, tap, focus, in-view, scroll, parallax, drag
🎯 Advanced dragaxis-lock, constraints, elasticity, momentum, snap points & moveWhileDrag
πŸ‘» Presenceleave animations finish before removal β€” for a single view or a keyed list
πŸͺœ Staggerordered list motion, plus staggerChildren orchestration inside variants
✍️ SVG path drawinganimate pathLength / pathOffset, WAAPI-powered
⏱️ Per-property transitionsdifferent duration, delay and easing per property; explicit keyframe times
πŸ”€ Motion valuesderive motion from Angular signals (moveValue, moveTransform, moveSpringValue)
πŸ–₯️ SSR-safeevery browser API guarded; no-ops on the server
🧱 Standalone-readytree-shakeable directives, no NgModule required

πŸš€ Quick start

npm install angular-movement
# or: pnpm add angular-movement Β· yarn add angular-movement

Peer dependencies: @angular/core and @angular/common β€” ^21.2.0 || ^22.0.0. Every supported major is compiled against the packed package in CI (pnpm validate:consumer).

1. Provide global defaults (optional)

import { ApplicationConfig } from '@angular/core';
import { provideMovement } from 'angular-movement';

export const appConfig: ApplicationConfig = {
  providers: [
    provideMovement({
      duration: '320ms', // or 320 β€” numbers are milliseconds
      easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
    }),
  ],
};

prefers-reduced-motion is honoured automatically β€” there is nothing to configure for it. disabled is an app-level kill switch (a user setting, screenshot tests), not the reduced-motion mechanism, and SSR needs nothing either: every directive is a no-op on the server.

2. Import only the directives you use

Standalone components tree-shake per route, but only if you import what you actually use β€” a directive that's never imported anywhere never ships.

import { Component } from '@angular/core';
import { MoveAnimateDirective, MoveHoverDirective, MoveTapDirective } from 'angular-movement';

@Component({
  selector: 'app-demo-card',
  imports: [MoveAnimateDirective, MoveHoverDirective, MoveTapDirective],
  template: `
    <h2 move="fade-up">Hello movement</h2>
    <button moveWhileHover="lift" moveWhileTap="press">Hover or press me</button>
  `,
})
export class DemoCardComponent {}

MOVEMENT_DIRECTIVES (all 21, spread into imports) is still exported as a convenience for prototyping or a component that genuinely uses most of the library β€” just know it pulls in everything, including directives that component doesn't use and every experimental one (moveLayout, moveDrag, moveSmoothScroll, moveTarget, moveTrigger). If you want a spread that can never silently start pulling in an experimental directive, use MOVEMENT_STABLE_DIRECTIVES instead (there's also a standalone MOVEMENT_EXPERIMENTAL_DIRECTIVES for the other five).

🧩 Pick the right primitive

Start with the smallest primitive that matches the job:

LevelReach for
BasicmoveEnter, moveLeave, [move], moveInitial, moveAnimate, moveExit
InteractionsmoveWhileHover, moveWhileTap, moveWhileFocus, moveInView
StatemoveVariants, moveTarget, moveTrigger
OrchestrationmovePresence, moveStagger
Scroll & layoutmoveScroll, moveParallax, moveLayout, moveSmoothScroll
AdvancedpathLength, pathOffset, transition, spring, moveDrag

moveLeave plays only while a parent movePresence keeps the view alive during removal. A plain @if removes the element immediately, so there is no node left to animate.

Several of these look interchangeable at first glance. They're not β€” each covers a distinct job:

  • [move] / moveAnimate vs [moveAnimation]. Both describe a single element's own enter/leave. [move]/moveAnimate take a preset name or MoveKeyframes pairs ({ opacity: [0, 1] }); [moveAnimation] takes Framer-style single-value state objects ({ initial, animate, exit }) and is reactive to animate changing. Reach for [moveAnimation] when you're already thinking in initial/animate/exit state, otherwise [move] is the simpler default.
  • moveVariants vs moveTarget/moveTrigger. moveVariants propagates a named state down through DI to nested [moveVariants] children β€” the tool for a shared state (idle, open, active) driving a subtree, with staggerChildren/delayChildren/when orchestration. moveTarget/moveTrigger (experimental) instead connect two elements that do not share a parent β€” a boolean signal flips an animation on a target elsewhere in the DOM, with no DI propagation. Prefer moveVariants whenever the elements involved share an ancestor.
  • moveStagger vs staggerChildren. [moveStagger] delays its direct animated children in DOM order β€” the tool for a flat list. staggerChildren (a moveVariants state property) staggers nested [moveVariants] subtrees on a variant change β€” the tool when the staggered items are themselves stateful, not just entering once.

πŸŽ›οΈ Explore the interactive playground

Every directive has a focused page with a live config panel and copy-paste HTML output.

Interactive demos playground

Demo pages: Animate Β· Animation (object API) Β· Enter & Leave Β· Hover & Tap Β· Focus Β· In-View Β· Scroll & Parallax Β· Presence Β· Layout Β· Drag Β· Variants Β· Text Β· SVG Icons

🧭 Common patterns

The shapes real apps use most β€” full explanations on the patterns page.

<!-- Product card: reveal in view, lift on hover -->
<article moveInView="fade-up" moveWhileHover="lift">…</article>

<!-- Button or link: lift (translate) + press (scale) compose on one element.
     Hover is mouse/pen only; touch gets moveWhileTap. Taps and scrolling stay native. -->
<a routerLink="/pricing" moveWhileHover="lift" moveWhileTap="press">Pricing</a>

<!-- Staggered grid: numbers are ms; strings say their unit -->
<ul moveStagger moveStaggerStep="80ms">
  @for (item of items(); track item.id) {
  <li moveInView="fade-up">{{ item.label }}</li>
  }
</ul>
// Imperative: MoveAnimator takes an Element or the ElementRef from viewChild()
const animator = inject(MoveAnimator);
await animator.animate(this.panel(), { opacity: [0, 1], y: [12, 0] }, { duration: '240ms' })
  ?.finished;
animator.set(this.panel(), { opacity: 1 }); // commit a state instantly; clear() removes it

// View Transition: animate the pseudo-element through the same API
animator.animate(
  document.documentElement,
  { clipPath: ['circle(0px at 40px 40px)', 'circle(1500px at 40px 40px)'] },
  { duration: '520ms', pseudoElement: '::view-transition-new(root)' },
);
  • Timing: every duration/delay/stagger takes 80, "80ms" or "0.08s". A bound 0.08 is 0.08 milliseconds β€” dev mode warns and suggests "80ms" / "0.08s".
  • Reduced motion: OS preference (automatic) β†’ provideMovement({ disabled }) (app kill switch) β†’ moveDisabled (one element).
  • Cancellation: finished and moveTrigger.play() always resolve, never reject β€” no try/catch around await.
  • Any CSS property passes through: { clipPath: [...] }, { filter: [...] }, { borderRadius: [...] }.
  • First paint: an above-the-fold entrance that must move before hydration belongs in CSS; the library takes over once the app runs.

πŸ“– Recipes

Motion-style API β€” initial / animate / exit
<ng-container *movePresence="isOpen">
  <article
    [moveInitial]="{ opacity: 0, y: 24 }"
    [moveAnimate]="{ opacity: 1, y: 0 }"
    [moveExit]="{ opacity: 0, y: -16 }"
    moveDuration="300ms"
  >
    Card
  </article>
</ng-container>

The object form [moveAnimation]="{ initial, animate, exit }" is also available for config-heavy cases.

Drag gestures β€” constraints, momentum, snap points
<div
  moveDrag="x"
  [moveDragConstraints]="{ left: -120, right: 120 }"
  [moveDragElastic]="0.35"
  [moveDragMomentum]="true"
  [moveDragSnapPoints]="[{ x: -120, y: 0 }, { x: 0, y: 0 }, { x: 120, y: 0 }]"
  (moveDragStart)="onDragStart($event)"
  (moveDragMove)="onDragMove($event)"
  (moveDragEnd)="onDragEnd($event)"
>
  Drag me
</div>

Use moveWhileTap for press feedback that returns on release; use moveDrag when the element should follow the pointer and keep a real position.

SVG path drawing & icon helpers

Animate pathLength from 0 to 1 to draw a stroke. The engine measures the element's total length and converts it to WAAPI-compatible strokeDasharray / strokeDashoffset keyframes.

<svg width="24" height="24" viewBox="0 0 24 24">
  <path
    [moveTarget]="animate()"
    [moveFrames]="{ pathLength: [0, 1], opacity: [0, 1] }"
    moveDuration="700ms"
    fill="none"
    stroke="currentColor"
    stroke-width="2"
    d="M4 12l4-4 4 4 8-8"
  />
</svg>

Helper functions build icon keyframes quickly:

import { movePathDraw, moveIconPulse } from 'angular-movement';
<svg [moveTarget]="animate()" movePreset="icon-bounce" moveDuration="500ms">
  <!-- icon paths -->
</svg>
Variants with per-property transitions

Declare target states like Framer Motion. Use moveVariant to set the active state; moveActiveVariant is a permanent, fully-supported alias for the same input (@deprecated only to signal which name to prefer β€” it is not going away). When the active variant changes, keyframes are generated from the previous state to the next.

<div
  [moveVariants]="{
    idle: { scale: 1, rotate: 0 },
    active: { scale: 1.08, rotate: 4 }
  }"
  [moveVariant]="isActive ? 'active' : 'idle'"
>
  Card
</div>

Override timing per property, and point moveExitVariant at the variant that plays before removal:

<ng-container *movePresence="isOpen">
  <aside
    [moveVariants]="{
      visible: { opacity: 1, x: 0 },
      hidden: { opacity: 0, x: 24 }
    }"
    moveVariant="visible"
    moveExitVariant="hidden"
  >
    Panel
  </aside>
</ng-container>
Motion values driven by signals

Called from a field initializer or constructor of a class Angular itself constructs β€” a component, directive, or service β€” moveSpringValue infers its injector automatically, the same convention toSignal/toObservable use:

import { Component, computed } from '@angular/core';
import { moveSpringValue, moveTransform, moveValue } from 'angular-movement';

@Component({ selector: 'app-card', template: `...` })
class CardComponent {
  progress = moveValue(0);
  x = moveTransform(this.progress, [0, 1], [0, 120]);
  scale = moveSpringValue(moveTransform(this.progress, [0, 1], [0.9, 1]));
  transform = computed(() => `translateX(${this.x()}px) scale(${this.scale()})`);
}

Calling it from outside an injection context (a plain function invoked later, a different injector than the surrounding one) still needs an explicit injector:

import { inject, Injector } from '@angular/core';
import { moveSpringValue } from 'angular-movement';

function buildScale(source: Signal<number>, injector: Injector) {
  return moveSpringValue(source, { injector });
}

moveSpringValue also respects prefers-reduced-motion automatically, same as every directive β€” under reduced motion it jumps straight to the target value instead of animating.

Scroll directives expose progress as a signal, so you can derive values without a manual scroll loop:

<section
  #scroll="moveScroll"
  [moveScroll]="{ opacity: [0, 1] }"
  [style.--progress]="scroll.progress()"
>
  Scroll-linked content
</section>

πŸ§ͺ API stability

StatusAPIs
StableprovideMovement, MOVEMENT_DIRECTIVES, MOVEMENT_STABLE_DIRECTIVES, [move], [moveAnimate], moveEnter, moveLeave, *movePresence, moveStagger, moveWhileHover, moveWhileTap, moveWhileFocus, moveInView, moveScroll, moveParallax, [moveAnimation], *movePresenceFor, moveVariants, moveText, moveLoop, MoveAnimator, moveValue, moveTransform, moveSpringValue, the preset library (MOVE_PRESETS and the icon helpers)
Stable candidate(none currently β€” the 1.0 freeze pass promoted every candidate; new APIs may land here first)
ExperimentalMOVEMENT_EXPERIMENTAL_DIRECTIVES, moveLayout, moveDrag (the whole directive β€” constraints, momentum, snap points, moveWhileDrag), moveSmoothScroll / SmoothScrollService, moveTarget, moveTrigger

MOVEMENT_DIRECTIVES itself is stable β€” spreading it always compiles and its own shape follows SemVer β€” but its contents are not stability-pure: it includes all five experimental directives above. Use MOVEMENT_STABLE_DIRECTIVES instead if you want a spread that can never silently start pulling in an experimental directive, or MOVEMENT_EXPERIMENTAL_DIRECTIVES for just those five.

Stable APIs follow semantic-versioning expectations. Candidate APIs are feature-complete but may receive small naming or behavior adjustments. Experimental APIs can change significantly between minor versions β€” see the versioning policy below for exactly what that means going into 1.x.

Every exported type mirrors the stability of the API it supports β€” MoveKeyframes is stable because the directives that take it are stable, MoveDragEvent is experimental because moveDrag is. AnimationControls (the return type shared by MoveAnimator and every directive internally) and the MovementConfig family (behind provideMovement) are stable on their own: their shape hasn't changed since 0.5 and both are load-bearing for everything else. moveActiveVariant is @deprecated in favor of moveVariant (same value, one name) but stays a permanent, fully-supported alias β€” it will not be removed without a major version.

Each level is also declared in the source as a @stability JSDoc tag (stable / candidate / experimental), so your editor shows the guarantee at the point of use β€” check the tag on the specific declaration you're using for the authoritative answer. Experimental declarations additionally carry the standard @experimental tag.

Experimental compatibility policy (going into 1.x)

There is no secondary angular-movement/experimental entry point β€” every experimental export ships from the same package. This is the one deliberate exception to normal SemVer:

  • Experimental exports may change or be removed in any 1.x minor, including breaking changes to inputs, outputs, or behavior β€” the same convention Angular CDK uses for its own experimental APIs.
  • Every experimental-only breaking change is called out under its own ### Changed (experimental) CHANGELOG heading, separate from the normal ### Changed, so you can safely ignore it if you don't use experimental APIs.
  • Where practical, an experimental API gets a deprecation warning (dev-mode console warning or @deprecated tag) for at least one minor version before removal.

If you only use [move], moveVariants, *movePresenceFor, moveScroll, and the rest of the Stable row above, normal SemVer applies to your app without exception.

This "no secondary entry point" decision was reaffirmed in the post-1.0 hardening pass (spec 013): the experimental surface still needs no dependency stable consumers shouldn't pay for, so splitting the package would be migration churn with no architectural win. It remains open for a future minor if a concrete reason appears.

πŸ”„ Input reactivity

Two deliberate groups, frozen for 1.0:

  • Reactive β€” changing an input while the directive is alive updates or replays the animation: moveWhileHover, moveWhileTap, moveWhileFocus, moveVariants, moveTarget, moveTrigger, moveScroll, moveParallax, moveDrag, moveLoop, moveText, and [moveAnimation]'s animate state.
  • One-shot by design β€” these describe a single entrance or exit, so they play once and ignore later input changes: moveAnimate / [move], moveEnter, moveLeave, moveInView, moveSmoothScroll. To play one again, wrap the element in *movePresence / *movePresenceFor or re-create the view.

[moveAnimation] compares its animate state by value, so binding an object literal straight in the template does not replay the animation on every change detection pass.

πŸ—οΈ Repository structure

This is a pnpm monorepo with two parts:

PathWhat
projects/movementThe publishable npm library (angular-movement)
srcDemo & documentation site β€” AnalogJS (Vite + SSR, zoneless)

The demo site imports the library via a Vite path alias, so library changes are reflected live without a build step.

pnpm dev            # run the demo site
pnpm test           # library unit tests (Vitest)
ng build movement   # build the library β†’ dist/movement
pnpm build          # build the demo site (client + SSR)

🚒 Deployment

The demo site deploys to Cloudflare Pages β€” a single source of truth for hosting.

Live at angular-movement.andersseen.dev.

🀝 Contributing

Contributions are welcome through issues and pull requests. When proposing changes, include a problem statement, any public-API impact, and tests or demo updates for new behavior.

πŸ“„ License

MIT Β© Andersseen

Built with Angular, AnalogJS & the Web Animations API.