Iron Law
NEVER ANIMATE LAYOUT PROPERTIES (width, height, top, left, margin) ON LARGE SURFACES. Default to transform and opacity only. Resolve compositor vs paint vs layout tier BEFORE writing any animation CSS.
Fixing Motion Performance — Angular 21.x
When to Use
Load this skill when:
- Angular CSS transitions or animations stutter or drop below 60fps
- Implementing scroll-linked motion or reveal-on-scroll in Angular templates
- Animating layout, filters, masks, gradients, or CSS variables
- Reviewing Angular component styles that use
will-change, transforms, or DOM measurement - Refactoring janky interactions in existing Angular components
How to Use as File Auditor
/fixing-motion-performance <file>
Review the file against all rules below and report:
- violations — quote the exact line or snippet
- why it matters — one short sentence
- concrete fix — code-level suggestion with before/after
Do not migrate animation libraries unless explicitly requested. Apply rules within the existing Angular/TailwindCSS/daisyUI stack.
Rendering Steps Glossary
Understanding which browser rendering step an animation triggers determines its performance cost:
| Step | Triggered By | Cost |
|---|---|---|
| composite | transform, opacity |
Cheapest — runs on compositor thread, never blocks main thread |
| paint | color, background, border, box-shadow, filter, gradients |
Medium — rerasters pixels |
| layout | width, height, top, left, margin, padding, flex, grid |
Most expensive — recalculates entire document flow |
Rule: Always animate at the cheapest tier that achieves the design intent.
Rule Categories by Priority
| Priority | Category | Impact |
|---|---|---|
| 1 | Never patterns | Critical |
| 2 | Choose the mechanism | Critical |
| 3 | Measurement | High |
| 4 | Scroll | High |
| 5 | Paint | Medium-High |
| 6 | Layers | Medium |
| 7 | Blur and filters | Medium |
| 8 | View transitions | Low |
| 9 | Tool boundaries | Critical |
1. Never Patterns (Critical)
These always cause jank. Zero exceptions.
- Do not interleave layout reads and writes in the same frame
- Do not animate layout properties continuously on large or meaningful surfaces
- Do not drive animation from
scrollTop,scrollY, orscrollevents - No
requestAnimationFrameloops without a stop condition - Do not mix multiple animation systems that each measure or mutate layout
2. Choose the Mechanism (Critical)
- Default to
transformandopacityfor all motion - Use JS-driven animation only when interaction requires it
- Paint or layout animation is acceptable only on small, isolated surfaces
- One-shot effects are acceptable more often than continuous motion
- Prefer downgrading technique over removing motion entirely
Angular + Tailwind mapping
| Intent | Wrong (causes layout/paint) | Correct (compositor) |
|---|---|---|
| Slide open panel | transition: width 0.3s |
transition: transform 0.3s + scaleX() |
| Fade in element | transition: display |
transition: opacity 0.2s + visibility |
| Card hover lift | transition: margin-top |
transition: transform 0.2s + translateY(-4px) |
| Alert shake | transition: left |
@keyframes with translateX |
| Expand accordion | transition: height 0.3s |
FLIP technique (see §3) or grid-template-rows |
3. Measurement (High)
- Measure once, then animate via
transformoropacity - Batch all DOM reads before writes — never interleave
- Do not read layout (
getBoundingClientRect,offsetHeight,scrollTop) during an animation - Prefer FLIP-style transitions for layout-like effects
FLIP Technique — Angular example
// Angular component: FLIP for expanding card
flipExpand(el: HTMLElement): void {
// FIRST — measure before state change
const first = el.getBoundingClientRect();
// Apply the state change (add class, toggle signal)
this.isExpanded.set(true);
// Force style recalc synchronously
el.offsetHeight; // force reflow once, intentionally
// LAST — measure after state change
const last = el.getBoundingClientRect();
// INVERT — animate from first to last position
const deltaY = first.top - last.top;
const deltaScale = first.height / last.height;
el.style.transform = `translateY(${deltaY}px) scaleY(${deltaScale})`;
el.style.transition = 'none';
// PLAY — release to natural position
requestAnimationFrame(() => {
el.style.transition = 'transform 0.3s ease-out';
el.style.transform = '';
});
}
4. Scroll (High)
- Prefer CSS
animation-timeline: view()orscroll()for scroll-linked motion - Use
IntersectionObserverfor visibility-triggered animations and pausing - Do not poll
scrollTop/scrollYfor animation in Angular components - Pause or stop animations when off-screen (use
IntersectionObserver) - Scroll-linked motion must not trigger continuous layout or paint on large surfaces
Angular scroll-reveal pattern
// BAD — scroll event listener causes layout thrash
@HostListener('window:scroll')
onScroll() {
const scrollY = window.scrollY; // forces layout read
this.opacity = Math.min(scrollY / 300, 1); // mutation in same frame
}
/* GOOD — CSS View Timeline, runs on compositor thread */
.reveal-card {
animation: slideUp linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
// GOOD — IntersectionObserver for class-based reveal
ngAfterViewInit(): void {
const observer = new IntersectionObserver(
(entries) => entries.forEach(e => {
if (e.isIntersecting) {
e.target.classList.add('animate-slide-up');
observer.unobserve(e.target); // one-shot
}
}),
{ threshold: 0.15 }
);
this.cards().forEach(card => observer.observe(card.nativeElement));
}
5. Paint (Medium-High)
- Paint-triggering animation is allowed only on small, isolated elements
- Do not animate paint-heavy properties on large containers
- Do not animate CSS variables that control
transform,opacity, orposition - Do not animate inherited CSS variables (the inheritance causes full repaint)
- Scope animated CSS variables locally and avoid inheritance
Angular + daisyUI context
/* BAD — animating background triggers paint on full container */
.hero { transition: background-color 1s; }
/* OK — small indicator badge, paint cost is acceptable */
.badge { transition: background-color 0.2s; }
/* BAD — animating inherited CSS variable causes subtree repaint */
:root { --card-bg: oklch(0.9 0 0); }
.card { transition: --card-bg 0.3s; } /* inherits, repaints all children */
/* GOOD — scope locally, no inheritance */
.card { transition: opacity 0.2s, transform 0.2s; }
6. Layers (Medium)
- Compositor motion requires layer promotion — never assume it happens automatically
- Use
will-changetemporarily and surgically (add before animation, remove after) - Avoid many or large promoted layers — each costs GPU memory
- Validate layer behavior with Chrome DevTools → Layers panel when performance matters
/* BAD — permanent will-change on all cards wastes GPU memory */
.card { will-change: transform; }
/* GOOD — add surgically before animation, remove after */
/* In Angular component: */
onHoverStart(el: HTMLElement): void {
el.style.willChange = 'transform';
}
onHoverEnd(el: HTMLElement): void {
el.style.willChange = 'auto'; // release layer after animation
}
7. Blur and Filters (Medium)
- Keep blur animation radius small (
backdrop-blurchanges ≤ 8px delta) - Use blur only for short, one-time effects (modal open, not continuous hover)
- Never animate
blur()orbackdrop-blurcontinuously - Never animate blur on large surfaces (full-page overlays)
- Prefer
opacityandtranslateoverblurfor reveal effects
/* BAD — continuous blur on large surface */
.hero-overlay { transition: backdrop-filter 0.5s; }
.hero-overlay:hover { backdrop-filter: blur(20px); } /* GPU-expensive on large area */
/* OK — one-shot modal open, small delta */
.modal-backdrop {
animation: blurIn 200ms ease-out forwards;
}
@keyframes blurIn {
from { opacity: 0; backdrop-filter: blur(0px); }
to { opacity: 1; backdrop-filter: blur(4px); } /* <= 8px delta */
}
8. View Transitions (Low)
Angular 21.x has native View Transition support via the router.
- Use view transitions only for navigation-level page changes
- Avoid view transitions for interaction-heavy UI (tabs, toggles, accordions)
- Avoid view transitions when interruption or cancellation is required
- Treat size changes in view transitions as potentially layout-triggering
// angular router — enable view transitions
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes, withViewTransitions({
onViewTransitionCreated: ({ transition }) => {
// Cancel if interrupted by rapid navigation
if (transition.skipTransition) return;
}
}))
]
});
9. Tool Boundaries (Critical)
- Do not migrate or rewrite animation libraries unless explicitly requested
- Apply these rules within the existing Angular/TailwindCSS/daisyUI animation system
- Never partially migrate APIs or mix Tailwind
transition-*and Angular Animations API in the same component - Do not introduce
framer-motion,GSAP, or other JS animation libraries — workspace uses CSS/WAAPI
Common Fixes (Angular Context)
/* layout thrashing: animate transform instead of width */
/* BEFORE */ .panel { transition: width 0.3s; }
/* AFTER */ .panel { transition: transform 0.3s; transform-origin: left; }
/* Use scaleX() to simulate width expansion without layout */
/* scroll-linked: use CSS View Timeline instead of JS scroll listener */
/* BEFORE (Angular @HostListener scroll) */
/* AFTER */
.reveal {
animation: fadeUp linear both;
animation-timeline: view();
animation-range: entry 0% entry 30%;
}
/* Tailwind: prefer translate over top/left */
/* BEFORE */ .dropdown { transition: top 0.2s; }
/* AFTER */ .dropdown { transition: translate 0.2s; }
/* Tailwind v4: translate-y-2 → translate-y-0 with transition-transform */
// FLIP measurement pattern — batch reads before writes
flipTransition(el: HTMLElement): void {
const first = el.getBoundingClientRect(); // read
this.expanded.set(true); // write (state change)
el.offsetHeight; // force single reflow
const last = el.getBoundingClientRect(); // read after write
const dy = first.top - last.top;
el.style.transform = `translateY(${dy}px)`;
el.style.transition = 'none';
requestAnimationFrame(() => {
el.style.transition = 'transform 0.25s ease-out';
el.style.transform = 'none';
});
}
Review Guidance
- Enforce critical rules first (never patterns, tool boundaries)
- Choose the least expensive rendering work that matches the design intent
- For any non-default choice, state the constraint that justifies it (surface size, duration, or interaction requirement)
- When reviewing, prefer actionable notes and concrete alternatives over theory
- Angular-specific: check whether
@HostListener('window:scroll')is being used for animation — always flag for replacement with CSS View Timeline or IntersectionObserver
Related Skills
angular-spa— Angular component patterns;reference/animations.mdcovers timing standards and keyframesweb-performance-optimization— Core Web Vitals, bundle size, lazy loading (loading performance, not animation runtime)tailwind-patterns— Tailwind v4@themeconfig and utility patternsaccessibility-audit— WCAG 2.1prefers-reduced-motioncompliance (complements this skill's performance rules)