From fastlane-ui-design
Design and implement microinteractions, motion design, transitions, and user feedback patterns in Angular with the Angular Animations API. Use when adding polish to UI interactions, implementing loading states, or creating delightful user experiences.
How this skill is triggered — by the user, by Claude, or both
Slash command
/fastlane-ui-design:interaction-designThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Create engaging, intuitive interactions through motion, feedback, and thoughtful state transitions that enhance usability and delight users.
Create engaging, intuitive interactions through motion, feedback, and thoughtful state transitions that enhance usability and delight users.
Motion should communicate, not decorate:
| Duration | Use Case |
|---|---|
| 100-150ms | Micro-feedback (hovers, clicks) |
| 200-300ms | Small transitions (toggles, dropdowns) |
| 300-500ms | Medium transitions (modals, page changes) |
| 500ms+ | Complex choreographed animations |
// Common easings for Angular Animations
--ease-out: cubic-bezier(0.16, 1, 0.3, 1); // Decelerate - entering
--ease-in: cubic-bezier(0.55, 0, 1, 0.45); // Accelerate - exiting
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); // Both - moving between
--spring: cubic-bezier(0.34, 1.56, 0.64, 1); // Overshoot - playful
import {
Component,
trigger,
transition,
style,
animate,
ChangeDetectionStrategy,
} from '@angular/animations';
@Component({
selector: 'app-fade-card',
template: `
<div [@fadeIn]>
<ng-content />
</div>
`,
animations: [
trigger('fadeIn', [
transition(':enter', [
style({ opacity: 0, transform: 'translateY(-8px)' }),
animate(
'200ms ease-out',
style({ opacity: 1, transform: 'translateY(0)' })
),
]),
transition(':leave', [
animate(
'150ms ease-in',
style({ opacity: 0, transform: 'translateY(-8px)' })
),
]),
]),
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FadeCardComponent {}
import {
Component,
trigger,
transition,
style,
animate,
query,
stagger,
ChangeDetectionStrategy,
} from '@angular/animations';
@Component({
selector: 'app-staggered-list',
template: `
<ul [@listStagger]="items().length">
@for (item of items(); track item.id) {
<li>{{ item.name }}</li>
}
</ul>
`,
animations: [
trigger('listStagger', [
transition('* => *', [
query(
':enter',
[
style({ opacity: 0, transform: 'translateX(-20px)' }),
stagger(50, animate('200ms ease-out')),
],
{ optional: true }
),
]),
]),
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StaggeredListComponent {
readonly items = input<ListItem[]>([]);
}
import {
Component,
trigger,
state,
style,
transition,
animate,
ChangeDetectionStrategy,
signal,
} from '@angular/animations';
import { MatButtonModule } from '@angular/material/button';
@Component({
selector: 'app-animated-button',
standalone: true,
imports: [MatButtonModule],
template: `
<button
mat-raised-button
color="primary"
[@pressAnimation]="buttonState()"
(mousedown)="onMouseDown()"
(mouseup)="onMouseUp()"
(mouseleave)="onMouseUp()"
>
<ng-content />
</button>
`,
animations: [
trigger('pressAnimation', [
state('normal', style({ transform: 'scale(1)' })),
state('pressed', style({ transform: 'scale(0.98)' })),
transition('normal <=> pressed', animate('100ms ease-in-out')),
]),
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AnimatedButtonComponent {
readonly buttonState = signal<'normal' | 'pressed'>('normal');
onMouseDown(): void {
this.buttonState.set('pressed');
}
onMouseUp(): void {
this.buttonState.set('normal');
}
}
import {
Component,
trigger,
transition,
style,
animate,
ChangeDetectionStrategy,
input,
output,
} from '@angular/animations';
@Component({
selector: 'app-animated-modal',
template: `
<div
[@modalAnimation]
class="modal-overlay"
(click)="close()"
>
<div class="modal-content" (click)="$event.stopPropagation()">
<h2>{{ title() }}</h2>
<ng-content />
</div>
</div>
`,
animations: [
trigger('modalAnimation', [
transition(':enter', [
style({ opacity: 0 }),
animate('200ms ease-out', style({ opacity: 1 })),
]),
transition(':leave', [
animate('150ms ease-in', style({ opacity: 0 })),
]),
]),
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AnimatedModalComponent {
readonly title = input<string>('');
readonly closed = output<void>();
close(): void {
this.closed.emit();
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out;
}
.animate-pulse {
animation: pulse 2s ease-in-out infinite;
}
.animate-spin {
animation: spin 1s linear infinite;
}
.card {
transition:
transform 0.2s ease-out,
box-shadow 0.2s ease-out,
background-color 0.2s ease-out;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
background-color: var(--surface-alt);
}
.card:active {
transform: translateY(-2px);
}
import { Component, input, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-card-skeleton',
standalone: true,
imports: [CommonModule],
template: `
<div class="card-skeleton">
<div class="skeleton skeleton-image" />
<div class="skeleton-content">
<div class="skeleton skeleton-title" />
<div class="skeleton skeleton-text" />
<div class="skeleton skeleton-text skeleton-text-short" />
</div>
</div>
`,
styleUrl: './card-skeleton.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CardSkeletonComponent {}
SCSS:
.skeleton {
background: linear-gradient(
90deg,
var(--surface) 0%,
var(--surface-alt) 50%,
var(--surface) 100%
);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.skeleton-image {
width: 100%;
aspect-ratio: 16/9;
border-radius: var(--radius-md);
margin-bottom: var(--spacing-md);
}
.skeleton-title {
width: 60%;
height: 1.5rem;
border-radius: var(--radius-sm);
margin-bottom: var(--spacing-sm);
}
.skeleton-text {
width: 100%;
height: 1rem;
border-radius: var(--radius-sm);
margin-bottom: var(--spacing-xs);
}
.skeleton-text-short {
width: 70%;
}
import {
Component,
trigger,
transition,
style,
animate,
input,
ChangeDetectionStrategy,
} from '@angular/animations';
@Component({
selector: 'app-progress-bar',
standalone: true,
template: `
<div class="progress-container">
<div [@progressAnimation] class="progress-fill" />
</div>
`,
styleUrl: './progress-bar.component.scss',
animations: [
trigger('progressAnimation', [
transition('* => *', [animate('300ms ease-out')]),
]),
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProgressBarComponent {
readonly progress = input<number>(0);
readonly style = computed(() => ({
width: `${this.progress()}%`,
}));
}
SCSS:
.progress-container {
width: 100%;
height: 4px;
background: var(--border-default);
border-radius: var(--radius-sm);
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--color-primary);
border-radius: var(--radius-sm);
}
Pangea's _buttons.scss defines a system-wide hover behavior for all mat-button variants:
transform: translateY(-3px) on hover; translateY(-1px) on activeThis is defined once in @pangea/ng-support — never override it per-component. If a button needs different hover behavior, question whether it's a button.
Trust Pangea's Material integration — don't add custom hover transforms to buttons.
import { Component, signal, effect, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-motion-aware',
template: `
<div [@fadeAnimation]="animationState()">
Content
</div>
`,
animations: [
trigger('fadeAnimation', [
transition(':enter', [
style({ opacity: 0 }),
animate(
`${this.prefersReducedMotion() ? 0 : 200}ms ease-out`,
style({ opacity: 1 })
),
]),
]),
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MotionAwareComponent {
readonly prefersReducedMotion = signal(false);
constructor() {
effect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
this.prefersReducedMotion.set(mediaQuery.matches);
const listener = (e: MediaQueryListEvent) => {
this.prefersReducedMotion.set(e.matches);
};
mediaQuery.addEventListener('change', listener);
return () => mediaQuery.removeEventListener('change', listener);
});
}
}
Or globally in CSS:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
import {
Component,
input,
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import { DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
@Component({
selector: 'app-sortable-list',
standalone: true,
imports: [CommonModule, DragDropModule],
template: `
<div cdkDropList (cdkDropListDropped)="drop($event)">
@for (item of items(); track item.id) {
<div cdkDrag class="sortable-item">
{{ item.name }}
</div>
}
</div>
`,
styleUrl: './sortable-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SortableListComponent {
readonly items = signal<SortableItem[]>([]);
drop(event: CdkDragDrop<SortableItem[]>): void {
if (event.previousContainer === event.container) {
moveItemInArray(
this.items(),
event.previousIndex,
event.currentIndex
);
}
}
}
SCSS:
.sortable-item {
padding: var(--spacing-md);
background: var(--surface);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
cursor: move;
transition: all 200ms ease-out;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
// Drag preview styling
.cdk-drag-preview {
opacity: 0.8;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
}
.cdk-drop-list-dragging .sortable-item:hover {
transform: scale(1.02);
}
transform and opacity for smooth 60fps animationsprefers-reduced-motionwidth, height, top, left — use transform insteadwill-change sparingly for optimizationaccessibility-compliance — respecting prefers-reduced-motion and keyboard-first designresponsive-design — timing adjustments for different device capabilitiesvisual-design-foundations — color transitions and token consistencynpx claudepluginhub pat-richardson/fastlane-ui-design --plugin fastlane-ui-designCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.