Add purposeful, accessible motion to Lightning Web Components. Every animation must serve a functional purpose and respect user preferences.
Core Principles
1Purposeful, not decorative — animation communicates state changes, not showing off
2prefers-reduced-motion is mandatory — always provide a no-animation fallback
3Fast — 150-300ms for most transitions; never more than 500ms
4Only animate transform and opacity — these are GPU-composited and performant
5Ease-out for entrances, ease-in for exits — matches natural physical motion
Reduced Motion (Mandatory)
Every component with animation must include this. No exceptions.
@media (prefers-reduced-motion: reduce) {
*,
*: :before,
*: :after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
Alternatively, per-element:
.animated-element {
transition: opacity 200ms ease-out, transform 200ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.animated-element {
transition: none;
}
}
Timing Reference
| Duration | Use Case | Easing |
| 100ms | Hover/focus state changes | ease |
| 150ms | Button press, toggle, micro-feedback | ease-out |
| 200ms | Panel expand/collapse, tab switch | ease-out |
| 300ms | Modal open, card entrance | ease-out |
| 400ms | Page-level transitions, staggered list | ease-out |
| 500ms | Maximum — complex multi-element sequences | ease-in-out |
Easing Functions
| Name | CSS | Motion Feel |
| Ease-out | cubic-bezier(0, 0, 0.2, 1) | Fast start, gentle stop (entrances) |
| Ease-in | cubic-bezier(0.4, 0, 1, 1) | Gentle start, fast stop (exits) |
| Ease-in-out | cubic-bezier(0.4, 0, 0.2, 1) | Symmetric (state changes) |
| Spring | cubic-bezier(0.34, 1.56, 0.64, 1) | Slight overshoot (playful) |
Entry Animations
Fade In
.fade-enter {
opacity: 0;
animation: fadeIn 300ms ease-out forwards;
}
@keyframes fadeIn {
to { opacity: 1; }
}
Slide Up + Fade
The most versatile entry animation — content rises into view.
.slide-up-enter {
opacity: 0;
transform: translateY(12px);
animation: slideUp 300ms ease-out forwards;
}
@keyframes slideUp {
to {
opacity: 1;
transform: translateY(0);
}
}
Scale In (Modals, Popovers)
.scale-enter {
opacity: 0;
transform: scale(0.95);
animation: scaleIn 200ms ease-out forwards;
}
@keyframes scaleIn {
to {
opacity: 1;
transform: scale(1);
}
}
Exit Animations
Fade Out
.fade-exit {
animation: fadeOut 200ms ease-in forwards;
}
@keyframes fadeOut {
to { opacity: 0; }
}
Slide Down + Fade (Reverse of entry)
.slide-down-exit {
animation: slideDown 200ms ease-in forwards;
}
@keyframes slideDown {
to {
opacity: 0;
transform: translateY(12px);
}
}
Staggered List Reveals
Show list items one by one with increasing delay for a cascading effect.
<class="code-tag">template>
<class="code-tag">ul class="stagger-list">
<class="code-tag">template for:each={items} for:item="item" for:index="index">
<class="code-tag">li key={item.id} class="stagger-item" style={item.staggerStyle}>
{item.name}
</class="code-tag">li>
</class="code-tag">template>
</class="code-tag">ul>
</class="code-tag">template>
get items() {
return this._rawItems.map((item, index) => ({
...item,
staggerStyle: class="code-string">`animation-delay: ${index * 50}ms`
}));
}
.stagger-item {
opacity: 0;
transform: translateY(8px);
animation: slideUp 300ms ease-out forwards;
}
.stagger-list {
display: flex;
flex-direction: column;
gap: var(--slds-g-spacing-2, 0.5rem);
}
State Transitions
Expand / Collapse
.expandable {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 200ms ease-out;
}
.expandable--open {
grid-template-rows: 1fr;
}
.expandable__inner {
overflow: hidden;
}
Tab Content Transition
.tab-panel {
opacity: 0;
transform: translateX(8px);
transition: opacity 200ms ease-out, transform 200ms ease-out;
}
.tab-panel--active {
opacity: 1;
transform: translateX(0);
}
Toggle / Switch
.toggle-track {
width: 40px;
height: 22px;
border-radius: var(--slds-g-radius-border-pill, 9999px);
background: var(--slds-g-color-surface-container-3, #e5e5e5);
transition: background-color 150ms ease;
position: relative;
cursor: pointer;
}
.toggle-track--active {
background: var(--slds-g-color-accent-1, #0176d3);
}
.toggle-thumb {
width: 18px;
height: 18px;
border-radius: var(--slds-g-radius-circle);
background: var(--slds-g-color-surface-1, #ffffff);
box-shadow: var(--slds-g-shadow-1);
position: absolute;
top: 2px;
left: 2px;
transition: transform 150ms ease-out;
}
.toggle-track--active .toggle-thumb {
transform: translateX(18px);
}
Loading Sequences
Skeleton Pulse (from sf-lwc-ux)
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.skeleton {
background: var(--slds-g-color-surface-container-2, #f3f3f3);
border-radius: var(--slds-g-radius-border-1, 0.125rem);
animation: pulse 1.5s ease-in-out infinite;
}
Shimmer Effect
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton-shimmer {
background: linear-gradient(
90deg,
var(--slds-g-color-surface-container-2, #f3f3f3) 25%,
var(--slds-g-color-surface-container-1, #f8f8f8) 50%,
var(--slds-g-color-surface-container-2, #f3f3f3) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: var(--slds-g-radius-border-1, 0.125rem);
}
Spinner with Context
<class="code-tag">template>
<class="code-tag">div class="loading-container" if:true={isLoading}>
<class="code-tag">lightning-spinner alternative-text="Loading" size="small"></class="code-tag">lightning-spinner>
<class="code-tag">span class="loading-text">{loadingMessage}</class="code-tag">span>
</class="code-tag">div>
</class="code-tag">template>
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--slds-g-spacing-3, 0.75rem);
padding: var(--slds-g-spacing-8, 2rem);
}
.loading-text {
font-size: var(--slds-g-font-size-2, 0.75rem);
color: var(--slds-g-color-on-surface-2, #444444);
animation: fadeIn 300ms ease-out 500ms both;
}
Demo Choreography Sequences
Patterns specifically designed to make demos and PoCs visually impressive.
Dashboard Card Cascade
Stagger dashboard metric cards for a "building the picture" effect:
async loadDashboard() {
this.isLoading = true;
const data = await getDashboardMetrics();
class="code-comment">// Assign staggered delays for visual cascade
this.metrics = data.map((metric, index) => ({
...metric,
cardStyle: class="code-string">`animation-delay: ${index * 80}ms`
}));
this.isLoading = false;
}
.dashboard-card {
opacity: 0;
transform: translateY(16px) scale(0.98);
animation: cardReveal 400ms ease-out forwards;
}
@keyframes cardReveal {
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
Number Counter Animation
Animate KPI values counting up from zero for dramatic effect:
animateValue(element, start, end, duration) {
const range = end - start;
const startTime = performance.now();
const step = (currentTime) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
class="code-comment">// Ease-out curve
const eased = 1 - Math.pow(1 - progress, 3);
const current = start + (range * eased);
element.textContent = this.formatValue(Math.round(current));
if (progress < 1) {
requestAnimationFrame(step);
}
};
requestAnimationFrame(step);
}
Data Refresh Pulse
When demo data updates (e.g., after saving a record), briefly pulse the updated values:
.value-updated {
animation: valueFlash 600ms ease-out;
}
@keyframes valueFlash {
0% { background-color: var(--slds-g-color-success-container-1, #e6f4ea); }
100% { background-color: transparent; }
}
class="code-comment">// After data refresh, mark changed values
handleDataRefresh(newData) {
this.metrics = newData.map((metric, i) => ({
...metric,
isUpdated: metric.value !== this._previousMetrics?.[i]?.value
}));
class="code-comment">// Clear the update flag after animation completes
setTimeout(() => {
this.metrics = this.metrics.map(m => ({ ...m, isUpdated: false }));
}, 700);
}
Coordinated Page Reveal
For demo landing pages, reveal sections in sequence:
.section-hero { animation: slideUp 400ms ease-out forwards; }
.section-metrics { animation: slideUp 400ms ease-out 150ms forwards; opacity: 0; }
.section-chart { animation: slideUp 400ms ease-out 300ms forwards; opacity: 0; }
.section-list { animation: slideUp 400ms ease-out 450ms forwards; opacity: 0; }
Anti-PatternsReference
| Do NOT | Do Instead |
Animate width, height, top, left | Animate transform and opacity |
| Animation longer than 500ms | Keep 150-300ms for most transitions |
| Animate on page load with no trigger | Animate in response to user action or data arrival |
| Bouncing/pulsing attention-grabbers | Subtle one-shot transitions |
Missing prefers-reduced-motion | Always include the media query |
animation-iteration-count: infinite (except loading) | Use forwards fill mode for one-shot |
Scoring Rubric (100 Points)Reference
| Category | Points | Pass Criteria |
| Reduced Motion | 25 | prefers-reduced-motion media query present; all animations disabled |
| Purpose | 20 | Every animation communicates a state change, not decorative |
| Performance | 20 | Only transform/opacity animated; no layout thrashing |
| Timing | 15 | Durations within 100-500ms range; appropriate easing per use case |
| SLDS Compliance | 10 | All colors/sizes in animations use --slds-g-* hooks |
| Consistency | 10 | Same animation patterns used across the component suite |
Cross-Skill IntegrationReference
| Skill | Relationship |
| sf-lwc-ux | Skeleton/loading states use motion patterns defined here |
| sf-lwc-mobile | Mobile animations must be faster; reduced motion critical |
| sf-lwc-styling | Utility classes can include transition properties |
| sf-lwc-design | All animated colors/sizes must use SLDS 2 hooks |
| sf-se-demo-scripts | Demo choreography sequences create impressive wow moments |