You've finished wiring Strapi into your Next.js stack and the data flows flawlessly—yet the UI still feels static. Every attempt to layer in animation either drags down performance or pushes Core Web Vitals into the red. Tailwind's transition utilities handle basic fades, but richer interactions need thoughtful CSS animation strategies combined with native browser APIs.
The solution comes down to restricting effects to GPU-accelerated properties like transform and opacity. Tailwind's utility-first approach maintains 60fps even on lower-end devices, while poor implementation choices inflate Cumulative Layout Shift scores.
This guide shows you how to combine Strapi, Tailwind, and motion animation patterns so you can ship engaging, animation-rich pages without compromising deadlines or performance budgets. Motion animations become just another architectural decision—one with clear, repeatable patterns.
In brief:
- Focus animations on GPU-friendly properties (
transformandopacity) to maintain 60fps performance across devices - Implement staggered animations to prevent frame drops when multiple components load simultaneously
- Use Intersection Observer to trigger animations only when content enters the viewport
- Structure CMS queries to deliver exactly the data your animations need without overfetching
Setting Up Strapi for Your Next.js Project
A clean Strapi setup takes minutes and gives you complete control over animation data delivery. When your backend stays strictly API-first, you decide exactly what each animation consumes and when it renders—crucial for maintaining 60 fps without overfetching.
Installing and Initializing the Strapi Client
Create your Next.js application using create-next-app, which sets up everything automatically for you:
1npx create-next-app@latest nextjs-projectMake sure to select "Yes" for Tailwind CSS during setup. For fetching data from Strapi, you can use the native fetch API or install a client library:
1npm install axiosCreate a helper so every component has one place to request data:
1// lib/strapi.js
2const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
3
4export async function fetchAPI(path, options = {}) {
5 const defaultOptions = {
6 headers: {
7 'Content-Type': 'application/json',
8 },
9 };
10
11 const mergedOptions = {
12 ...defaultOptions,
13 ...options,
14 };
15
16 const requestUrl = `${STRAPI_URL}${path}`;
17
18 const response = await fetch(requestUrl, mergedOptions);
19
20 if (!response.ok) {
21 throw new Error(`Strapi responded with ${response.status}`);
22 }
23
24 const data = await response.json();
25 return data;
26}The wrapper centralizes error handling, so a single retry policy covers every animated component.
Configuring API Authentication with Tokens
In the Strapi Admin Panel, navigate to Settings → API Tokens → Create new. Give the token a short-lived TTL and restrict it to collections your frontend actually animates—the principle of least privilege prevents accidental data leaks.
Store the token in an environment file:
1# .env.local
2NEXT_PUBLIC_STRAPI_URL=https://cms.example.com
3STRAPI_API_TOKEN=your-token-hereInject the header when you make requests:
1export async function fetchAPI(path, options = {}) {
2 const defaultOptions = {
3 headers: {
4 'Content-Type': 'application/json',
5 Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
6 },
7 };
8
9 const mergedOptions = {
10 ...defaultOptions,
11 ...options,
12 };
13
14 const requestUrl = `${STRAPI_URL}${path}`;
15
16 const response = await fetch(requestUrl, mergedOptions);
17
18 if (!response.ok) {
19 throw new Error(`Strapi responded with ${response.status}`);
20 }
21
22 const data = await response.json();
23 return data;
24}Use separate keys per environment—development, preview, production—so staging animations run safely against draft content without risking production data corruption.
Fetching Strapi Content for Motion Animation
Smooth motion begins with predictable, low-latency data. Strapi's REST endpoints deliver JSON you can shape, filter, and transform before a single frame animates. By aligning your queries with how your components render, you eliminate wasted round-trips and the stuttering they cause.
Making GET Requests to Strapi Endpoints
Every Strapi Collection Type is automatically exposed at /api/{pluralName}. Because the response structure never changes, you can wire it directly into your animation pipeline:
1// lib/fetchPosts.js
2export async function fetchPosts() {
3 const res = await fetch(
4 `${process.env.NEXT_PUBLIC_STRAPI_URL}/api/posts?sort=publishedAt:desc`
5 );
6
7 if (!res.ok) {
8 // Catch issues early—missing data breaks animations
9 throw new Error(`Strapi responded with ${res.status}`);
10 }
11
12 const { data } = await res.json();
13 return data; // [{ id, documentId, title, excerpt, … }]
14}Keeping the fetch function isolated lets you pipe the resolved data into animated components without coupling network logic to UI code.
Using the Populate Parameter for Related Content
Nested relationships can sink frame rates if you resolve them one request at a time. Strapi's populate parameter sidesteps the N + 1 problem by bundling relations into a single payload:
1GET /api/posts?populate=author.avatar,cover&sort=publishedAt:descHere, author avatars and cover images ship with each post, so your animation has everything it needs the moment it enters the viewport. For deep hierarchies, chain paths—populate[comments][populate]=author—but populate only what the component will actually animate to avoid bloated responses.
Filtering Content for Specific Animation Sections
Filtering trims download size and keeps animation lists short enough to stay at 60 fps. The platform supports operators like $eq, $gte, and $in, which you can combine with pagination:
1GET /api/posts?filters[category][slug][$eq]=frontend&pagination[page]=1&pagination[pageSize]=6Use published status to hide drafts from production or date ranges to animate seasonal promotions. Because filters are pure query-string, you can update them dynamically—based on a user's route param—without a rebuild. The lighter the payload, the less memory your animations allocate, and the smoother every spring, fade, and stagger will feel.
Core Motion Animation Patterns with Strapi Data
Animations kill performance when you animate the wrong properties. Stick to GPU-friendly transforms and opacity, use Tailwind utilities, and leverage native browser APIs to keep your content smooth on mobile. These patterns run client-side, work with any CMS response, and respect prefers-reduced-motion through Tailwind's accessibility helpers.
Fade-In Animations on Content Load
Fade-ins only touch opacity—practically free for the GPU. Add a keyframe in your Tailwind configuration:
1// tailwind.config.js
2module.exports = {
3 theme: {
4 extend: {
5 keyframes: {
6 'fade-in': {
7 from: { opacity: '0' },
8 to: { opacity: '1' },
9 },
10 },
11 animation: {
12 'fade-in': 'fade-in 400ms ease-out forwards',
13 },
14 },
15 },
16};When content loads, apply the class:
1<div className="animate-fade-in">{article.title}</div>Avoid animating off-screen content with an IntersectionObserver:
1const observer = new IntersectionObserver(
2 ([entry]) => {
3 if (entry.isIntersecting) {
4 entry.target.classList.add('animate-fade-in');
5 }
6 },
7 { threshold: 0.2 }
8);
9
10observer.observe(document.querySelector('#article'));Stagger Animations for Lists and Card Collections
The CMS returns arrays—perfect for stagger effects. Use CSS custom properties to derive delay from array index while keeping Tailwind for layout:
1{posts.map((post, i) => (
2 <article
3 key={post.id}
4 className="bg-white rounded-xl shadow p-6 opacity-0 animate-fade-in"
5 style={{
6 animationDelay: `${i * 80}ms`,
7 }}
8 >
9 <h2>{post.title}</h2>
10 </article>
11))}Add a slide-up effect by extending your Tailwind config:
1keyframes: {
2 'slide-up': {
3 from: { opacity: '0', transform: 'translateY(20px)' },
4 to: { opacity: '1', transform: 'translateY(0)' },
5 },
6},
7animation: {
8 'slide-up': 'slide-up 400ms ease-out forwards',
9},Disable animations for reduced motion preferences:
1@media (prefers-reduced-motion: reduce) {
2 * {
3 animation-duration: 0.01ms !important;
4 animation-iteration-count: 1 !important;
5 transition-duration: 0.01ms !important;
6 }
7}Scroll-Triggered Animations for Content Sections
Defer animations until sections enter the viewport. IntersectionObserver beats scroll listeners every time:
1function revealOnScroll(node) {
2 const io = new IntersectionObserver(
3 ([entry]) => {
4 if (entry.isIntersecting) {
5 node.classList.add('animate-fade-in');
6 io.disconnect();
7 }
8 },
9 { threshold: 0.15 }
10 );
11
12 io.observe(node);
13}Attach revealOnScroll to any content—hero sections, galleries, or marketing blocks—so animation fires only when visible.
Hover Effects on Media and Interactive Elements
Hover animations are user-triggered, so they never delay first paint. Tailwind handles simple cases:
1<img
2 src={image.url}
3 alt={image.alternativeText}
4 className="transition-transform duration-300 hover:scale-105"
5/>For complex effects like card lifts with shadow changes:
1<div className="rounded-lg overflow-hidden bg-white transition-all duration-300 hover:-translate-y-2 hover:shadow-xl">
2 {/* Card content */}
3</div>Touch devices don't emit hover events, so mobile stays performant by default. Combining Tailwind utilities with native CSS transitions gives you readable animation logic while the headless CMS delivers clean data.
Building Motion Animation Components with Strapi
Structured content pairs naturally with CSS-based animations. The patterns below focus on performance, accessibility, and real-world implementation—treating media delivery and fallback states as architectural requirements, not afterthoughts.
Animated Hero Sections with Dynamic Content
Hero sections carry your heaviest assets, so every optimization counts. Fetch your hero content in one request using populate=*, then animate only GPU-friendly properties like opacity and transform:
1export default function Hero({ data }) {
2 return (
3 <section className="relative flex items-center justify-center h-[60vh] md:h-[80vh] overflow-hidden animate-fade-in">
4 <img
5 src={`${data.background.url}?w=1600&auto=format`}
6 alt={data.background.alternativeText}
7 className="absolute inset-0 w-full h-full object-cover"
8 />
9 <div className="relative z-10 text-center text-white px-6">
10 <h1 className="text-3xl md:text-5xl font-bold mb-4 animate-slide-up" style={{ animationDelay: '200ms' }}>
11 {data.title}
12 </h1>
13 <p className="max-w-xl mx-auto animate-slide-up" style={{ animationDelay: '400ms' }}>
14 {data.subtitle}
15 </p>
16 </div>
17 </section>
18 );
19}Image transformations serve optimized formats automatically. Animating only opacity and transform keeps performance smooth across devices.
Image Galleries with Strapi's Upload API
Stagger your gallery reveals to prevent frame drops when multiple images load simultaneously:
1function Gallery({ images }) {
2 return (
3 <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
4 {images.map((img, i) => (
5 <img
6 key={img.id}
7 src={img.formats.small?.url || img.url}
8 alt={img.alternativeText}
9 className="w-full h-auto rounded-lg opacity-0 animate-fade-in"
10 style={{ animationDelay: `${i * 50}ms` }}
11 loading="lazy"
12 />
13 ))}
14 </div>
15 );
16}Lazy loading defers requests until needed, while staggered delays prevent main thread blocking on large collections.
Card Grids with Orchestrated Animation Sequences
Map collections to animated cards using index-based delays:
1function CardGrid({ articles }) {
2 return (
3 <div className="grid md:grid-cols-3 gap-6">
4 {articles.map((post, i) => (
5 <article
6 key={post.id}
7 className="bg-white rounded-xl shadow-sm hover:shadow-lg transition-shadow opacity-0 animate-slide-up"
8 style={{ animationDelay: `${i * 100}ms` }}
9 >
10 <img
11 src={post.cover.url}
12 alt={post.cover.alternativeText}
13 className="w-full h-48 object-cover rounded-t-xl"
14 />
15 <div className="p-4">
16 <h3 className="font-semibold text-lg">{post.title}</h3>
17 </div>
18 </article>
19 ))}
20 </div>
21 );
22}Independent card animations maintain 60fps even with large datasets, especially when combined with pagination.
Loading States and Text Reveal Animations
Never leave users staring at a blank space. Show skeleton screens while content loads, then reveal it smoothly:
1function PostSkeleton() {
2 return (
3 <div className="animate-pulse space-y-4">
4 <div className="h-48 bg-gray-300 rounded"></div>
5 <div className="h-4 bg-gray-300 rounded w-3/4"></div>
6 <div className="h-4 bg-gray-300 rounded w-5/6"></div>
7 </div>
8 );
9}
10
11function TextReveal({ children }) {
12 return (
13 <p className="leading-relaxed opacity-0 animate-fade-in">
14 {children}
15 </p>
16 );
17}Tailwind's animate-pulse handles loading states, while custom animations provide smooth content transitions.
Styling Motion Animations with Tailwind CSS
You already use Tailwind for layout and color, so it makes sense to use the framework for motion styling too. The utility-first mindset means every animation detail—duration, easing, delay—lives in the markup next to the component it affects.
That proximity keeps intent obvious when you or a teammate revisit the code months later. Because Tailwind compiles only the classes you use, you're not shipping unused keyframes that bloat the bundle.
Using Tailwind's Built-In Transition Utilities
Start with the out-of-the-box transition helpers—they handle most animation needs without JavaScript. Classes such as transition-opacity, transition-transform, and duration-500 animate GPU-accelerated properties, so you keep scroll performance solid even on low-power devices.
The framework pairs these with easing modifiers (ease-out, ease-in-out, etc.) for complete control over timing:
1{/* Fade a content card in once its data arrives */}
2<li className="transition-opacity duration-500 ease-out opacity-0 data-[loaded=true]:opacity-100">
3 {article.title}
4</li>Because the animation touches only opacity, it stays silky smooth. You can vary timing responsively by stacking utilities—md:duration-700—so longer animations play only on screens that can spare the milliseconds.
Creating Custom Animation Classes in Tailwind Config
Brand guidelines often demand motion that generic utilities can't supply. Tailwind lets you wire bespoke keyframes into tailwind.config.js:
1// tailwind.config.js
2export default {
3 content: ['./src/**/*.{js,jsx,ts,tsx}'],
4 theme: {
5 extend: {
6 keyframes: {
7 'fade-in': {
8 '0%': { opacity: '0' },
9 '100%': { opacity: '1' },
10 },
11 'slide-up': {
12 '0%': { opacity: '0', transform: 'translateY(20px)' },
13 '100%': { opacity: '1', transform: 'translateY(0)' },
14 },
15 },
16 animation: {
17 'fade-in': 'fade-in 0.4s ease-out both',
18 'slide-up': 'slide-up 0.5s ease-out both',
19 },
20 },
21 },
22}Now you drop animate-fade-in anywhere—maybe on a hero image—and the framework generates the minimal CSS required. Because unused classes disappear during the build step, your stylesheet stays lean.
Keep custom sets concise; a handful of well-named keyframes usually meet 90% of design requests without turning the CSS file into a library of one-offs.
Combining Tailwind with Web Animations API
For interactions that CSS alone can't handle—spring physics, gesture-driven states, orchestrated timelines—reach for the Web Animations API:
1import { useEffect, useRef } from 'react';
2
3export default function AnimatedCard({ post }) {
4 const cardRef = useRef(null);
5
6 useEffect(() => {
7 const observer = new IntersectionObserver(
8 ([entry]) => {
9 if (entry.isIntersecting) {
10 cardRef.current.animate(
11 [
12 { opacity: 0, transform: 'translateY(24px)' },
13 { opacity: 1, transform: 'translateY(0)' }
14 ],
15 {
16 duration: 400,
17 easing: 'ease-out',
18 fill: 'forwards'
19 }
20 );
21 observer.disconnect();
22 }
23 },
24 { threshold: 0.2 }
25 );
26
27 observer.observe(cardRef.current);
28
29 return () => observer.disconnect();
30 }, []);
31
32 return (
33 <article
34 ref={cardRef}
35 className="bg-white rounded-xl p-4 shadow-sm"
36 >
37 <h2 className="text-lg font-semibold">{post.title}</h2>
38 </article>
39 );
40}Here Tailwind handles color, spacing, and layout, while the Web Animations API animates the fetched card into view only when it enters the viewport—no wasted cycles off-screen.
Remember to respect accessibility with prefers-reduced-motion:
1const shouldReduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
2
3if (!shouldReduceMotion) {
4 cardRef.current.animate([/* animations */], {/* options */});
5}Advanced Motion Animation Techniques
Well-structured animations break down under real-world pressure: live content edits, complex publishing workflows, and Core Web Vitals budgets. These tactics give you complete control over execution and timing without sacrificing performance or accessibility.
Live Preview with Animated Content Updates
Preview environments fail when animations drift out of sync with unpublished content. A lightweight postMessage bridge fixes this issue by maintaining real-time synchronization:
1// hooks/useStrapiPreview.js
2import { useEffect, useRef } from "react";
3
4export function useStrapiPreview(ref, debounce = 150) {
5 const timer = useRef();
6
7 useEffect(() => {
8 function handleMessage(event) {
9 if (event.origin !== process.env.NEXT_PUBLIC_STRAPI_URL) return;
10
11 clearTimeout(timer.current);
12 timer.current = setTimeout(() => {
13 try {
14 const data = JSON.parse(event.data);
15 if (data?.type === "preview:update" && ref.current) {
16 // Retrigger fade animation
17 ref.current.classList.remove('animate-fade-in');
18 void ref.current.offsetWidth; // Force reflow
19 ref.current.classList.add('animate-fade-in');
20 }
21 } catch (err) {
22 console.error("Invalid preview payload", err);
23 }
24 }, debounce);
25 }
26
27 window.addEventListener("message", handleMessage);
28 return () => window.removeEventListener("message", handleMessage);
29 }, [debounce, ref]);
30}Attach the hook to any component you want to refresh inside the preview:
1const heroRef = useRef(null);
2useStrapiPreview(heroRef);
3
4return (
5 <section ref={heroRef} className="animate-fade-in">
6 {/* Hero content */}
7 </section>
8);Conditional Animations Based on Content Type and Status
Granular motion control clarifies hierarchy and reduces visual noise. Drive that logic directly from the API response:
1function AnimatedEntry({ entry }) {
2 const getAnimationClass = () => {
3 switch (entry.type) {
4 case 'article':
5 return 'animate-fade-in';
6 case 'product':
7 return 'animate-slide-up';
8 default:
9 return 'animate-fade-in';
10 }
11 };
12
13 const getDraftStyles = () => {
14 if (entry.status === 'draft') {
15 return {
16 boxShadow: '0 0 0.5rem 0.2rem rgb(255 200 0 / 0.6)',
17 };
18 }
19 return {};
20 };
21
22 return (
23 <article
24 className={`bg-white rounded-lg p-6 shadow ${getAnimationClass()}`}
25 style={getDraftStyles()}
26 >
27 <h2 className="text-xl font-semibold">{entry.title}</h2>
28 <p>{entry.summary}</p>
29 </article>
30 );
31}This pattern highlights drafts without separate CSS files, swaps animation styles per content-type for clear visual cues, and extends quickly—accent urgent announcements with animate-pulse from the utility set.
Optimizing Performance for Animation-Heavy Pages
Large motion sequences must respect Core Web Vitals—especially CLS and TBT. Combine modern APIs with GPU-friendly properties:
1// utils/observeMotion.js
2export function observeMotion(el, animationClass) {
3 const shouldReduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
4 if (shouldReduce) return;
5
6 const io = new IntersectionObserver(
7 ([entry]) => {
8 if (entry.isIntersecting) {
9 el.classList.add(animationClass);
10 io.disconnect();
11 }
12 },
13 { threshold: 0.2 }
14 );
15
16 io.observe(el);
17}Usage:
1const ref = useRef(null);
2
3useEffect(() => {
4 if (ref.current) {
5 observeMotion(ref.current, 'animate-fade-in');
6 }
7}, []);Key performance principles:
- Animate only
transformandopacity—they bypass layout and avoid layout shifts - Defer non-critical assets with Intersection Observer to avoid blocking LCP
- Keep CSS bundles lean by using Tailwind's purge feature
- Always respect
prefers-reduced-motion—Tailwind'smotion-reducevariant works out of the box
Pairing data-driven logic with these guardrails maintains smooth 60 fps interactions without jeopardizing accessibility or Core Web Vitals.
Practical Motion Animation Examples
Copy-paste these patterns, drop in your endpoints, and ship. Each example animates GPU-friendly properties and respects prefers-reduced-motion, keeping Core Web Vitals healthy while editors control timing from the CMS.
Blog Post Cards with Entrance Animations
Fetch posts and stagger their reveal. Query only what you need (filters[publishedAt][$ne]=null and tight populate) to minimize payload size—smaller JSON means smoother frames:
1// components/PostList.jsx
2import { useState, useEffect } from 'react';
3
4export default function PostList() {
5 const [posts, setPosts] = useState([]);
6 const [loading, setLoading] = useState(true);
7
8 useEffect(() => {
9 async function fetchPosts() {
10 try {
11 const response = await fetch(
12 `${process.env.NEXT_PUBLIC_STRAPI_URL}/api/posts?populate=cover&fields=title,slug`
13 );
14 const { data } = await response.json();
15 setPosts(data);
16 } catch (error) {
17 console.error('Error fetching posts:', error);
18 } finally {
19 setLoading(false);
20 }
21 }
22
23 fetchPosts();
24 }, []);
25
26 if (loading) return <p>Loading…</p>;
27
28 return (
29 <section className="grid gap-6 md:grid-cols-2">
30 {posts.map((post, i) => (
31 <article
32 key={post.id}
33 className="bg-white rounded-xl shadow p-6 opacity-0 animate-slide-up"
34 style={{ animationDelay: `${i * 80}ms` }}
35 >
36 <h2 className="text-xl font-semibold">{post.title}</h2>
37 </article>
38 ))}
39 </section>
40 );
41}Add loading="lazy" to images to keep LCP low. CSS animations automatically respect the OS prefers-reduced-motion flag when configured properly.
Dynamic Navigation Menus with Motion Effects
Store menu items in a Collection Type so content editors can reorder without code changes. Slide-down animation driven by Tailwind utilities keeps the bundle slim:
1// components/Nav.jsx
2import { useState } from "react";
3
4export default function Nav({ links }) {
5 const [open, setOpen] = useState(false);
6
7 return (
8 <nav className="border-b">
9 <button
10 onClick={() => setOpen(!open)}
11 className="p-4 md:hidden"
12 aria-expanded={open}
13 >
14 ☰
15 </button>
16
17 {open && (
18 <ul className="md:hidden bg-gray-100 flex flex-col animate-slide-down">
19 {links.map((l) => (
20 <li key={l.id} className="border-t">
21 <a
22 href={l.href}
23 className="block px-6 py-4 hover:bg-gray-200 transition-colors"
24 >
25 {l.label}
26 </a>
27 </li>
28 ))}
29 </ul>
30 )}
31 </nav>
32 );
33}Add this keyframe to your Tailwind config:
1'slide-down': {
2 '0%': { opacity: '0', maxHeight: '0' },
3 '100%': { opacity: '1', maxHeight: '500px' },
4},Larger screens render the same data as a static horizontal menu, eliminating CLS risk.
Page Transitions and CMS-Controlled Animation Timing
The platform exposes timing knobs—duration, easing, delay—so editors adjust page transitions without shipping new code:
1// app/layout.jsx
2import { useRouter } from 'next/router';
3import { useEffect, useState } from 'react';
4
5export default function Layout({ children }) {
6 const router = useRouter();
7 const [config, setConfig] = useState({ duration: 400, easing: 'ease-in-out' });
8
9 useEffect(() => {
10 async function fetchConfig() {
11 const response = await fetch('/api/animation-settings');
12 const { data } = await response.json();
13 if (data) {
14 setConfig(data);
15 }
16 }
17
18 fetchConfig();
19 }, []);
20
21 return (
22 <main
23 key={router.asPath}
24 className="min-h-screen animate-fade-in"
25 style={{
26 animationDuration: `${config.duration}ms`,
27 animationTimingFunction: config.easing,
28 }}
29 >
30 {children}
31 </main>
32 );
33}Transitions only touch opacity and transform, sailing through performance audits. Editors tweaking timing see instant updates in preview mode, accelerating iteration without another deployment.
Ship Motion-Rich Apps with Strapi
The animation patterns covered here work with any content structure, maintaining 60fps performance across fade-ins, staggers, and scroll triggers. Each component integrates directly into existing Next.js pages without restructuring your back-end logic.
The headless architecture gives you complete control over content delivery and animation timing. Populate only the fields your animations require, stream them through CSS and native browser APIs, and maintain Core Web Vitals by focusing on GPU-friendly properties like transform and opacity.
This API-first approach keeps future animation requests—whether marketing features or real-time dashboards—as front-end concerns you control.
These patterns accelerate development timelines while supporting SEO, security, and governance requirements.
From sophisticated page transitions to responsive image galleries, motion animations become a natural extension of your content management workflow rather than a complex technical challenge. Get a Strapi demo today.