I've shipped staggered animations in CitizenApp's dashboard at least a dozen times, and every single time I've seen developers—including myself early on—reach straight for Framer Motion. The mental model is seductive: "I need orchestrated animations, so I need a motion library." But this thinking costs you bundle size, runtime overhead, and honestly, unnecessary complexity.
Here's the truth: Tailwind's animation system, layered with CSS custom properties and animation-delay, gives you 95% of what Framer Motion provides for dashboard loading states, and it ships zero JavaScript.
The Problem We're Actually Solving
CitizenApp's dashboard loads AI feature results asynchronously. We have widgets for:
- Sentiment analysis
- Entity extraction
- Content classification
- Risk scoring
Each completes at different times, and we want them to cascade onto the screen smoothly rather than popping in all at once. That cascade matters for UX—it gives visual breathing room and suggests progressive computation.
I used to reach for Framer Motion because I needed:
- Per-element delay variation — each widget starts its animation at a different time
- Reusable animation definitions — not hardcoding delays in inline styles
- Consistency — same timing across different dashboard views
Here's what I've learned: CSS custom properties + Tailwind's native animation utilities handle all three without a single line of motion library code.
The Solution: CSS Custom Properties + Tailwind Animation
Let me show you the pattern that works:
// components/AIFeatureWidget.tsx
interface AIFeatureWidgetProps {
title: string;
delay: number; // in milliseconds
isLoading: boolean;
children: React.ReactNode;
}
export function AIFeatureWidget({
title,
delay,
isLoading,
children,
}: AIFeatureWidgetProps) {
return (
<div
className="animate-fadeInSlide"
style={{
"--animation-delay": `${delay}ms`,
} as React.CSSProperties}
>
<div className="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<h3 className="mb-4 text-sm font-semibold text-slate-900">{title}</h3>
{isLoading ? (
<div className="h-12 animate-pulse bg-slate-100 rounded" />
) : (
children
)}
</div>
</div>
);
}
The key is that inline --animation-delay CSS variable. Now, in your Tailwind config:
// tailwind.config.js
export default {
theme: {
extend: {
animation: {
fadeInSlide: "fadeInSlide 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards var(--animation-delay, 0ms)",
},
keyframes: {
fadeInSlide: {
"0%": {
opacity: "0",
transform: "translateY(8px)",
},
"100%": {
opacity: "1",
transform: "translateY(0)",
},
},
},
},
},
};
Notice var(--animation-delay, 0ms) in the animation definition. That's the magic. Now every element with animate-fadeInSlide respects its individual delay.
Usage in the Dashboard Layout
// pages/Dashboard.tsx
import { AIFeatureWidget } from "@/components/AIFeatureWidget";
import { useDashboardData } from "@/hooks/useDashboardData";
const FEATURE_ORDER = [
{ id: "sentiment", title: "Sentiment Analysis", delay: 0 },
{ id: "entities", title: "Entity Extraction", delay: 120 },
{ id: "classification", title: "Content Classification", delay: 240 },
{ id: "risk", title: "Risk Scoring", delay: 360 },
];
export default function Dashboard() {
const { data, isLoading } = useDashboardData();
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{FEATURE_ORDER.map((feature) => (
<AIFeatureWidget
key={feature.id}
title={feature.title}
delay={feature.delay}
isLoading={isLoading}
>
{/* Render results */}
<div className="text-sm text-slate-600">
{data?.[feature.id] || "No data"}
</div>
</AIFeatureWidget>
))}
</div>
);
}
This is declarative, maintainable, and performant. You're not managing animation state. You're not tracking component refs. You're setting a CSS variable and letting the browser's animation engine do the work.
Why Not Just Use Class Names for Each Widget?
I did this early on. You create animate-fadeInSlide-0, animate-fadeInSlide-120, animate-fadeInSlide-240, etc., by extending Tailwind's animation config. It works, but it's a maintenance nightmare once you have more than three or four widgets. CSS variables scale. Class proliferation doesn't.
The Performance Reality
- Bundle size: Zero JavaScript for animation logic
- Runtime: Browser's native animation engine handles it; no JavaScript execution during the animation itself
- Smoothness: 60fps on most devices because it's pure CSS transforms
-
Accessibility: Respects
prefers-reduced-motionby default if you configure it properly:
// tailwind.config.js
export default {
theme: {
extend: {
animation: {
fadeInSlide: "fadeInSlide 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards var(--animation-delay, 0ms)",
},
},
},
corePlugins: {
animation: process.env.NODE_ENV === "production",
},
};
Actually, don't use that last line—Tailwind respects prefers-reduced-motion out of the box. I was overthinking it.
What I Missed
The first time I shipped this pattern in CitizenApp, I didn't account for re-renders. If your parent component re-renders and the widget unmounts/remounts, the animation replays. On a real dashboard with live polling, this is jarring.
The fix: Wrap your widget list in useMemo or use a key that doesn't change:
const memoizedFeatures = useMemo(
() => FEATURE_ORDER.map((feature) => ({ ...feature, id: feature.id })),
[] // Empty deps—features list never changes
);
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{memoizedFeatures.map((feature) => (
<AIFeatureWidget key={feature.id} {...feature} />
))}
</div>
);
Or, simpler: just ensure your keys are stable. Don't use array indices.
When to Reach for Framer Motion
If you need spring physics, gesture-driven animations, or complex orchestration across deeply nested components, Framer Motion earns its bundle size. But for dashboard loading cascades? For staggered list reveals? For simple entrance animations?
CSS custom properties + Tailwind is the right tool. It's simpler, faster, and more maintainable than reaching for a motion library.
Top comments (0)