Next.js course · Module 3: Styling and Visual Optimization

Animations and Transitions in the User Interface in Next.js 16

18 min read ·
In this lesson10

In Metropolis Quantum, nothing stands still. Vehicles move smoothly between districts, holographic displays change with elegant transitions, and interactive city elements respond to residents with subtle but meaningful animations. This dynamism not only provides visual appeal but above all improves the usability and intuitiveness of the city's systems.

Similarly in web applications - well-designed animations and transitions can significantly impact user experience, making the interface more natural, informative, and pleasant to use. In this chapter, we will learn different techniques for implementing animations and transitions in Next.js 16 applications.

Types of Animations in Web Interfaces

Before we move on to specific implementations, it is worth knowing the different categories of animations found in web interfaces:

1. Micro-interaction Animations

Subtle animations responding to user actions, e.g.:

  • Button hover effects
  • Click animations
  • Loading indicators
  • Form state changes

2. State Transition Animations

Smooth transitions between different component states:

  • Expanding menus
  • Enlarging/shrinking elements
  • Element visibility changes
  • Mode switching (light/dark)

3. Navigation Animations

Animations accompanying page or view changes:

  • Page transitions
  • Tab switching
  • Page scrolling
  • Parallax effects

4. Narrative Animations

More complex animations presenting a story or process:

  • Onboarding sequences
  • Process illustrations
  • Animated infographics
  • Visual storytelling

Implementing Animations in React and Next.js 16

The React and Next.js ecosystem offers many ways to implement animations:

1. CSS and CSS Transitions

The simplest and most efficient way to animate simple changes:

/* app/globals.css */
.button {
  background-color: #3a86ff;
  color: white;
  padding: 8px 16px;
  border-radius: 4px;
  transition: transform 0.2s ease, background-color 0.2s ease;
}

.button:hover {
  transform: translateY(-2px);
  background-color: #2563eb;
}

2. CSS Animations

For more complex animations with defined cycles:

/* app/globals.css */
@keyframes fadeIn {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.card {
  animation: fadeIn 0.5s ease forwards;
}

/* Sequential animations for a list of elements */
.card:nth-child(1) { animation-delay: 0.1s; }
.card:nth-child(2) { animation-delay: 0.2s; }
.card:nth-child(3) { animation-delay: 0.3s; }

3. React Hooks and useState

For animations dependent on React states:

// components/ExpandablePanel.tsx
'use client';

import { useState } from 'react';
import styles from './ExpandablePanel.module.css';

export function ExpandablePanel({ title, children }) {
  const [isExpanded, setIsExpanded] = useState(false);

  return (
    <div className={styles.panel}>
      <button
        className={styles.header}
        onClick={() => setIsExpanded(!isExpanded)}
      >
        <h3>{title}</h3>
        <span className={`${styles.icon} ${isExpanded ? styles.iconExpanded : ''}`}>
          ▼
        </span>
      </button>
      <div className={`
        ${styles.content}
        ${isExpanded ? styles.contentExpanded : styles.contentCollapsed}
      `}>
        {children}
      </div>
    </div>
  );
}
/* components/ExpandablePanel.module.css */
.panel {
  border: 1px solid #ddd;
  border-radius: 8px;
  overflow: hidden;
  margin-bottom: 16px;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px;
  background-color: #f9f9f9;
  cursor: pointer;
  width: 100%;
  text-align: left;
  border: none;
}

.icon {
  transition: transform 0.3s ease;
}

.iconExpanded {
  transform: rotate(180deg);
}

.content {
  padding: 0 16px;
  transition: all 0.3s ease;
  overflow: hidden;
}

.contentExpanded {
  max-height: 500px; /* Adjust to your needs */
  padding: 16px;
  opacity: 1;
}

.contentCollapsed {
  max-height: 0;
  padding-top: 0;
  padding-bottom: 0;
  opacity: 0;
}

4. React Transition Group

A library that simplifies managing element transitions in React:

npm install react-transition-group
// components/Notification.tsx
'use client';

import { useState, useEffect } from 'react';
import { CSSTransition } from 'react-transition-group';
import styles from './Notification.module.css';

interface NotificationProps {
  message: string;
  type?: 'success' | 'error' | 'info';
  duration?: number;
}

export function Notification({ message, type = 'info', duration = 3000 }: NotificationProps) {
  const [show, setShow] = useState(true);

  useEffect(() => {
    const timer = setTimeout(() => {
      setShow(false);
    }, duration);

    return () => clearTimeout(timer);
  }, [duration]);

  return (
    <CSSTransition
      in={show}
      timeout={300}
      classNames={{
        enter: styles.notificationEnter,
        enterActive: styles.notificationEnterActive,
        exit: styles.notificationExit,
        exitActive: styles.notificationExitActive,
      }}
      unmountOnExit
    >
      <div className={`${styles.notification} ${styles[type]}`}>
        {message}
      </div>
    </CSSTransition>
  );
}
/* components/Notification.module.css */
.notification {
  position: fixed;
  bottom: 20px;
  right: 20px;
  padding: 16px;
  border-radius: 8px;
  color: white;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  z-index: 1000;
}

.success {
  background-color: #10b981;
}

.error {
  background-color: #ef4444;
}

.info {
  background-color: #3b82f6;
}

.notificationEnter {
  opacity: 0;
  transform: translateY(20px);
}

.notificationEnterActive {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 300ms, transform 300ms;
}

.notificationExit {
  opacity: 1;
}

.notificationExitActive {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 300ms, transform 300ms;
}

5. Framer Motion

Framer Motion is a powerful animation library for React that simplifies creating complex animations:

npm install framer-motion
// components/Card.tsx
'use client';

import { motion } from 'framer-motion';
import styles from './Card.module.css';

interface CardProps {
  title: string;
  description: string;
  imageUrl: string;
  index: number;
}

export function Card({ title, description, imageUrl, index }: CardProps) {
  return (
    <motion.div
      className={styles.card}
      initial={{ opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{
        duration: 0.5,
        delay: index * 0.1,
        ease: "easeOut"
      }}
      whileHover={{
        scale: 1.05,
        boxShadow: "0 10px 30px rgba(0, 0, 0, 0.15)"
      }}
    >
      <div className={styles.imageContainer}>
        <img src={imageUrl} alt={title} />
      </div>
      <div className={styles.content}>
        <h3>{title}</h3>
        <p>{description}</p>
      </div>
    </motion.div>
  );
}

6. CSS Modules with Next.js

Next.js allows easy combination of CSS Modules with components:

// components/Button.tsx
'use client';

import { useState } from 'react';
import styles from './Button.module.css';

interface ButtonProps {
  children: React.ReactNode;
  onClick?: () => void;
}

export function Button({ children, onClick }: ButtonProps) {
  const [isPressed, setIsPressed] = useState(false);

  const handleClick = () => {
    setIsPressed(true);
    setTimeout(() => setIsPressed(false), 200);
    onClick && onClick();
  };

  return (
    <button
      className={`${styles.button} ${isPressed ? styles.pressed : ''}`}
      onClick={handleClick}
    >
      {children}
    </button>
  );
}
/* components/Button.module.css */
.button {
  background-color: #3a86ff;
  color: white;
  border: none;
  border-radius: 4px;
  padding: 10px 20px;
  font-size: 16px;
  cursor: pointer;
  transition: all 0.2s ease;
  position: relative;
  overflow: hidden;
}

.button:hover {
  background-color: #2563eb;
}

.button:before {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: 0;
  height: 0;
  background-color: rgba(255, 255, 255, 0.3);
  border-radius: 50%;
  transform: translate(-50%, -50%);
  transition: width 0.4s ease-out, height 0.4s ease-out;
}

.button:hover:before {
  width: 300px;
  height: 300px;
}

.pressed {
  transform: scale(0.98);
}

Page Transition Animations in Next.js 16

Next.js 16 uses React 19.2, so besides classic techniques such as CSS animations or Framer Motion, you can use React's <ViewTransition> component. In the App Router it works without any configuration, because every navigation is a transition, and browsers without View Transitions API support simply show the new page without animation.

1. Basic Transitions Using CSS

// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="pl">
      <body>
        <main className="page-transition">
          {children}
        </main>
      </body>
    </html>
  );
}
/* app/globals.css */
.page-transition {
  animation: fadeIn 0.5s ease-in-out;
}

@keyframes fadeIn {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

2. Advanced Transitions with Framer Motion

// components/PageTransition.tsx
'use client';

import { motion } from 'framer-motion';

const variants = {
  hidden: { opacity: 0, x: -200, y: 0 },
  enter: { opacity: 1, x: 0, y: 0 },
  exit: { opacity: 0, x: 0, y: -100 },
};

export function PageTransition({ children }: { children: React.ReactNode }) {
  return (
    <motion.div
      variants={variants}
      initial="hidden"
      animate="enter"
      exit="exit"
      transition={{ type: 'linear', duration: 0.5 }}
    >
      {children}
    </motion.div>
  );
}
// app/page.tsx
import { PageTransition } from '@/components/PageTransition';

export default function HomePage() {
  return (
    <PageTransition>
      <h1>Welcome to Metropolis Quantum</h1>
      {/* Rest of content */}
    </PageTransition>
  );
}

3. Transitions Using next/navigation

// components/Navigation.tsx
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import styles from './Navigation.module.css';

export function Navigation() {
  const router = useRouter();
  const [isNavigating, setIsNavigating] = useState(false);

  const handleNavigate = (href: string) => {
    setIsNavigating(true);
    setTimeout(() => {
      router.push(href);
    }, 500); // Give time for exit animation
  };

  return (
    <nav className={`${styles.nav} ${isNavigating ? styles.navigating : ''}`}>
      <ul>
        <li>
          <a onClick={() => handleNavigate('/')}>Home</a>
        </li>
        <li>
          <a onClick={() => handleNavigate('/about')}>About us</a>
        </li>
        <li>
          <a onClick={() => handleNavigate('/services')}>Services</a>
        </li>
        <li>
          <a onClick={() => handleNavigate('/contact')}>Contact</a>
        </li>
      </ul>
    </nav>
  );
}
/* components/Navigation.module.css */
.nav {
  transition: opacity 0.5s ease;
}

.navigating {
  opacity: 0.5;
  pointer-events: none;
}

Implementing Complex UI Animations with Framer Motion

Framer Motion is particularly useful for creating complex, interactive UI animations. Let's look at a few examples:

1. Animated Mobile Menu

// components/MobileMenu.tsx
'use client';

import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import Link from 'next/link';
import styles from './MobileMenu.module.css';

export function MobileMenu() {
  const [isOpen, setIsOpen] = useState(false);

  const menuVariants = {
    closed: {
      opacity: 0,
      x: '100%',
      transition: {
        type: 'spring',
        stiffness: 400,
        damping: 40,
      },
    },
    open: {
      opacity: 1,
      x: 0,
      transition: {
        type: 'spring',
        stiffness: 400,
        damping: 40,
        staggerChildren: 0.1,
        delayChildren: 0.2,
      },
    },
  };

  const menuItemVariants = {
    closed: { opacity: 0, x: 50 },
    open: { opacity: 1, x: 0 },
  };

  const buttonVariants = {
    closed: { rotate: 0 },
    open: { rotate: 90 },
  };

  return (
    <div className={styles.mobileMenuContainer}>
      <motion.button
        className={styles.menuButton}
        onClick={() => setIsOpen(!isOpen)}
        variants={buttonVariants}
        animate={isOpen ? 'open' : 'closed'}
        transition={{ duration: 0.3 }}
      >
        <div className={styles.hamburger}>
          <span className={`${styles.line} ${isOpen ? styles.line1Open : ''}`}></span>
          <span className={`${styles.line} ${isOpen ? styles.line2Open : ''}`}></span>
          <span className={`${styles.line} ${isOpen ? styles.line3Open : ''}`}></span>
        </div>
      </motion.button>

      <AnimatePresence>
        {isOpen && (
          <motion.div
            className={styles.menuOverlay}
            variants={menuVariants}
            initial="closed"
            animate="open"
            exit="closed"
          >
            <nav className={styles.mobileNav}>
              <ul>
                {['Home', 'About', 'Services', 'Contact'].map((item) => (
                  <motion.li key={item} variants={menuItemVariants}>
                    <Link href={item === 'Home' ? '/' : `/${item.toLowerCase()}`}>
                      {item}
                    </Link>
                  </motion.li>
                ))}
              </ul>
            </nav>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}
/* components/MobileMenu.module.css */
.mobileMenuContainer {
  position: fixed;
  top: 20px;
  right: 20px;
  z-index: 1000;
}

.menuButton {
  background-color: #3a86ff;
  border: none;
  width: 50px;
  height: 50px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  z-index: 1001;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.hamburger {
  width: 24px;
  height: 18px;
  position: relative;
}

.line {
  position: absolute;
  width: 100%;
  height: 2px;
  background-color: white;
  transition: all 0.3s ease;
}

.line:nth-child(1) {
  top: 0;
}

.line:nth-child(2) {
  top: 8px;
}

.line:nth-child(3) {
  top: 16px;
}

.line1Open {
  top: 8px;
  transform: rotate(45deg);
}

.line2Open {
  opacity: 0;
}

.line3Open {
  top: 8px;
  transform: rotate(-45deg);
}

.menuOverlay {
  position: fixed;
  top: 0;
  right: 0;
  width: 300px;
  height: 100vh;
  background-color: white;
  box-shadow: -5px 0 15px rgba(0, 0, 0, 0.1);
  z-index: 1000;
}

.mobileNav {
  padding: 80px 40px;
}

.mobileNav ul {
  list-style: none;
  padding: 0;
  margin: 0;
}

.mobileNav li {
  margin-bottom: 20px;
}

.mobileNav a {
  font-size: 24px;
  color: #333;
  text-decoration: none;
  transition: color 0.3s ease;
}

.mobileNav a:hover {
  color: #3a86ff;
}

2. Animated Cards with a Flip Effect

// components/FlipCard.tsx
'use client';

import { useState } from 'react';
import { motion } from 'framer-motion';
import styles from './FlipCard.module.css';

interface FlipCardProps {
  frontContent: React.ReactNode;
  backContent: React.ReactNode;
}

export function FlipCard({ frontContent, backContent }: FlipCardProps) {
  const [isFlipped, setIsFlipped] = useState(false);

  const frontVariants = {
    flipped: { rotateY: 180, opacity: 0 },
    unflipped: { rotateY: 0, opacity: 1 }
  };

  const backVariants = {
    flipped: { rotateY: 0, opacity: 1 },
    unflipped: { rotateY: -180, opacity: 0 }
  };

  return (
    <div
      className={styles.flipCardContainer}
      onClick={() => setIsFlipped(!isFlipped)}
    >
      <motion.div
        className={styles.flipCardFront}
        variants={frontVariants}
        animate={isFlipped ? 'flipped' : 'unflipped'}
        transition={{ duration: 0.6, type: 'spring', stiffness: 300, damping: 20 }}
      >
        {frontContent}
      </motion.div>

      <motion.div
        className={styles.flipCardBack}
        variants={backVariants}
        animate={isFlipped ? 'flipped' : 'unflipped'}
        transition={{ duration: 0.6, type: 'spring', stiffness: 300, damping: 20 }}
      >
        {backContent}
      </motion.div>
    </div>
  );
}
/* components/FlipCard.module.css */
.flipCardContainer {
  width: 300px;
  height: 400px;
  perspective: 1000px;
  cursor: pointer;
  position: relative;
}

.flipCardFront,
.flipCardBack {
  position: absolute;
  width: 100%;
  height: 100%;
  backface-visibility: hidden;
  border-radius: 16px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  padding: 20px;
}

.flipCardFront {
  background-color: #f9f9f9;
  color: #333;
}

.flipCardBack {
  background-color: #3a86ff;
  color: white;
}

3. Scroll Progress Bar

// components/ScrollProgressBar.tsx
'use client';

import { useState, useEffect } from 'react';
import { motion, useScroll } from 'framer-motion';
import styles from './ScrollProgressBar.module.css';

export function ScrollProgressBar() {
  const { scrollYProgress } = useScroll();

  return (
    <motion.div
      className={styles.progressBar}
      style={{ scaleX: scrollYProgress }}
    />
  );
}
/* components/ScrollProgressBar.module.css */
.progressBar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 5px;
  background-color: #3a86ff;
  transform-origin: 0%;
  z-index: 1000;
}

Animation Performance Optimization

Animations can be taxing on the browser, so it is worth applying optimization methods:

1. Use CSS Properties That Do Not Cause Reflow

Best properties to animate:

  • transform
  • opacity
  • filter

Avoid animating:

  • width, height
  • top, left, right, bottom
  • margin, padding

2. Use will-change Carefully

.animated-element {
  will-change: transform, opacity;
  /* Rest of styles */
}

Note: Overusing will-change can worsen performance.

3. Lazy Loading Heavy Animation Libraries

// components/AnimatedSection.tsx
'use client';

import { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';

// Lazy loading Framer Motion
const motion = dynamic(() => import('framer-motion').then((mod) => mod.motion), {
  ssr: false,
  loading: () => <div>Loading...</div>,
});

export function AnimatedSection({ children }) {
  const [isClient, setIsClient] = useState(false);

  useEffect(() => {
    setIsClient(true);
  }, []);

  if (!isClient) {
    return <div>{children}</div>;
  }

  return (
    <motion.div
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      transition={{ duration: 0.5 }}
    >
      {children}
    </motion.div>
  );
}

4. Using GPU for Animations

.accelerated {
  transform: translateZ(0); /* Enables GPU acceleration */
}

Animation Accessibility

Remember that some users may prefer reduced animations, e.g., due to vestibular disorders.

1. Respecting System Preferences

@media (prefers-reduced-motion: reduce) {
  /* Disable or simplify animations */
  * {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

2. Implementing an Animation Toggle

// components/AnimationToggle.tsx
'use client';

import { useState, useEffect } from 'react';
import styles from './AnimationToggle.module.css';

export function AnimationToggle() {
  const [areAnimationsEnabled, setAreAnimationsEnabled] = useState(true);

  useEffect(() => {
    // Check system preferences
    const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    setAreAnimationsEnabled(!prefersReducedMotion);

    // Check saved user preferences
    const savedPreference = localStorage.getItem('animations-enabled');
    if (savedPreference !== null) {
      setAreAnimationsEnabled(savedPreference === 'true');
    }
  }, []);

  const toggleAnimations = () => {
    const newValue = !areAnimationsEnabled;
    setAreAnimationsEnabled(newValue);
    document.documentElement.classList.toggle('reduce-animations', !newValue);
    localStorage.setItem('animations-enabled', String(newValue));
  };

  return (
    <button
      className={styles.toggleButton}
      onClick={toggleAnimations}
      aria-pressed={!areAnimationsEnabled}
    >
      {areAnimationsEnabled ? 'Disable animations' : 'Enable animations'}
    </button>
  );
}
/* app/globals.css */
.reduce-animations * {
  animation-duration: 0.01ms !important;
  animation-iteration-count: 1 !important;
  transition-duration: 0.01ms !important;
  scroll-behavior: auto !important;
}

Scroll-dependent Animations

1. Basic Scroll Animation with Intersection Observer

// components/FadeInSection.tsx
'use client';

import { useRef, useEffect, useState } from 'react';
import styles from './FadeInSection.module.css';

export function FadeInSection({ children }) {
  const [isVisible, setIsVisible] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        setIsVisible(entry.isIntersecting);
      },
      {
        root: null,
        rootMargin: '0px',
        threshold: 0.1,
      }
    );

    if (ref.current) {
      observer.observe(ref.current);
    }

    return () => {
      if (ref.current) {
        observer.unobserve(ref.current);
      }
    };
  }, []);

  return (
    <div
      ref={ref}
      className={`${styles.fadeInSection} ${isVisible ? styles.isVisible : ''}`}
    >
      {children}
    </div>
  );
}
/* components/FadeInSection.module.css */
.fadeInSection {
  opacity: 0;
  transform: translateY(30px);
  transition: opacity 1s ease, transform 1s ease;
}

.isVisible {
  opacity: 1;
  transform: translateY(0);
}

2. Advanced Scroll Animations with Framer Motion

// components/ScrollAnimation.tsx
'use client';

import { useRef } from 'react';
import { motion, useScroll, useTransform } from 'framer-motion';
import styles from './ScrollAnimation.module.css';

export function ScrollAnimation() {
  const ref = useRef<HTMLDivElement>(null);

  const { scrollYProgress } = useScroll({
    target: ref,
    offset: ["start end", "end start"]
  });

  const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [0, 1, 0]);
  const scale = useTransform(scrollYProgress, [0, 0.5, 1], [0.8, 1, 0.8]);
  const rotate = useTransform(scrollYProgress, [0, 0.5, 1], [-15, 0, 15]);

  return (
    <div ref={ref} className={styles.container}>
      <motion.div
        className={styles.animatedBox}
        style={{ opacity, scale, rotate }}
      >
        <h2>Scroll animation</h2>
        <p>This section animates based on scroll position.</p>
      </motion.div>
    </div>
  );
}
/* components/ScrollAnimation.module.css */
.container {
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
}

.animatedBox {
  padding: 40px;
  background-color: #3a86ff;
  color: white;
  border-radius: 16px;
  max-width: 500px;
  text-align: center;
}

Use Cases in Different Contexts

1. Animated Data Tables

// components/DataTable.tsx
'use client';

import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import styles from './DataTable.module.css';

interface DataItem {
  id: string;
  name: string;
  value: number;
  category: string;
}

interface DataTableProps {
  data: DataItem[];
}

export function DataTable({ data }: DataTableProps) {
  const [sortField, setSortField] = useState<keyof DataItem>('name');
  const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');

  const handleSort = (field: keyof DataItem) => {
    if (field === sortField) {
      setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
    } else {
      setSortField(field);
      setSortDirection('asc');
    }
  };

  const sortedData = [...data].sort((a, b) => {
    if (a[sortField] < b[sortField]) return sortDirection === 'asc' ? -1 : 1;
    if (a[sortField] > b[sortField]) return sortDirection === 'asc' ? 1 : -1;
    return 0;
  });

  return (
    <div className={styles.tableContainer}>
      <table className={styles.table}>
        <thead>
          <tr>
            <th onClick={() => handleSort('name')}>
              Name {sortField === 'name' && (sortDirection === 'asc' ? '↑' : '↓')}
            </th>
            <th onClick={() => handleSort('value')}>
              Value {sortField === 'value' && (sortDirection === 'asc' ? '↑' : '↓')}
            </th>
            <th onClick={() => handleSort('category')}>
              Category {sortField === 'category' && (sortDirection === 'asc' ? '↑' : '↓')}
            </th>
          </tr>
        </thead>
        <motion.tbody layout>
          <AnimatePresence>
            {sortedData.map((item) => (
              <motion.tr
                key={item.id}
                initial={{ opacity: 0, y: 20 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: -20 }}
                transition={{ duration: 0.3 }}
                layout
              >
                <td>{item.name}</td>
                <td>{item.value}</td>
                <td>{item.category}</td>
              </motion.tr>
            ))}
          </AnimatePresence>
        </motion.tbody>
      </table>
    </div>
  );
}

2. Animated Chat Interface

// components/ChatInterface.tsx
'use client';

import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import styles from './ChatInterface.module.css';

interface Message {
  id: number;
  text: string;
  sender: 'user' | 'assistant';
  timestamp: Date;
}

export function ChatInterface() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [inputText, setInputText] = useState('');

  const handleSend = () => {
    if (!inputText.trim()) return;

    // Add user message
    const userMessage: Message = {
      id: Date.now(),
      text: inputText,
      sender: 'user',
      timestamp: new Date(),
    };

    setMessages([...messages, userMessage]);
    setInputText('');

    // Simulate assistant response
    setTimeout(() => {
      const assistantMessage: Message = {
        id: Date.now() + 1,
        text: `I received your message: "${inputText}"`,
        sender: 'assistant',
        timestamp: new Date(),
      };

      setMessages((prev) => [...prev, assistantMessage]);
    }, 1000);
  };

  return (
    <div className={styles.chatContainer}>
      <div className={styles.chatMessages}>
        <AnimatePresence>
          {messages.map((message) => (
            <motion.div
              key={message.id}
              className={`${styles.message} ${styles[message.sender]}`}
              initial={{ opacity: 0, y: 20, scale: 0.9 }}
              animate={{ opacity: 1, y: 0, scale: 1 }}
              transition={{ duration: 0.3 }}
            >
              <div className={styles.messageContent}>
                {message.text}
              </div>
              <div className={styles.timestamp}>
                {message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
              </div>
            </motion.div>
          ))}
        </AnimatePresence>
      </div>

      <div className={styles.chatInput}>
        <input
          type="text"
          value={inputText}
          onChange={(e) => setInputText(e.target.value)}
          onKeyPress={(e) => e.key === 'Enter' && handleSend()}
          placeholder="Write a message..."
        />
        <button onClick={handleSend}>Send</button>
      </div>
    </div>
  );
}

3. Animated Theme Selection

// components/ThemeToggle.tsx
'use client';

import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import styles from './ThemeToggle.module.css';

export function ThemeToggle() {
  const [isDarkMode, setIsDarkMode] = useState(false);

  useEffect(() => {
    // Check system preferences or previously saved settings
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    const savedTheme = localStorage.getItem('theme');
    const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');

    setIsDarkMode(initialTheme === 'dark');
    document.documentElement.classList.toggle('dark-theme', initialTheme === 'dark');
  }, []);

  const toggleTheme = () => {
    const newMode = !isDarkMode;
    setIsDarkMode(newMode);
    document.documentElement.classList.toggle('dark-theme', newMode);
    localStorage.setItem('theme', newMode ? 'dark' : 'light');
  };

  return (
    <button
      className={styles.themeToggle}
      onClick={toggleTheme}
      aria-label={isDarkMode ? 'Switch to light theme' : 'Switch to dark theme'}
    >
      <motion.div
        className={styles.toggleTrack}
        animate={{ backgroundColor: isDarkMode ? '#3a86ff' : '#ccc' }}
      >
        <motion.div
          className={styles.toggleThumb}
          animate={{
            x: isDarkMode ? 22 : 0,
            backgroundColor: isDarkMode ? '#121212' : '#fff',
          }}
          transition={{ type: 'spring', stiffness: 500, damping: 30 }}
        >
          <motion.div
            className={styles.iconContainer}
            animate={{ opacity: isDarkMode ? 1 : 0 }}
          >
            {/* Moon icon */}
            ◐
          </motion.div>
          <motion.div
            className={styles.iconContainer}
            animate={{ opacity: isDarkMode ? 0 : 1 }}
          >
            {/* Sun icon */}
            ●
          </motion.div>
        </motion.div>
      </motion.div>
    </button>
  );
}

Best Practices for Creating Animations

  1. Purposefulness: Every animation should have a purpose - whether informational or navigational
  2. Subtlety: In most cases, subtle animations are better than exaggerated ones
  3. Consistency: Maintain a consistent animation language throughout the application
  4. Performance: Optimize animations for better performance
  5. Accessibility: Respect user preferences regarding reduced animations
  6. Responsiveness: Adapt animations to different screen sizes
  7. Testing: Test animations on different devices and browsers

Summary

Animations and transitions are powerful tools that can significantly impact your Next.js 16 application's user experience. Just like in Metropolis Quantum, where smooth animations of holographic displays and city systems make technology more intuitive and friendly, well-designed animations in your application improve usability, engagement, and overall impressions.

Key points:

  1. Choose the right tools - from simple CSS to advanced libraries like Framer Motion
  2. Remember performance - optimize animations for smoothness and low resource consumption
  3. Maintain accessibility - respect user preferences regarding reduced animations
  4. Maintain consistency - create an animation language consistent with your application's identity
  5. Purpose over decoration - every animation should serve a specific function

In the next chapter, we will look at advanced performance features of Next.js 16 that will allow your application to run smoothly and quickly, just like the most advanced systems in Metropolis Quantum.

Code for this lesson: app/page.tsx
// Dark Mode & Theme System - Quantum Metropolis
'use client';

import { useState } from 'react';

export default function DarkModeDemo() {
  const [theme, setTheme] = useState<'dark' | 'light' | 'neon'>('dark');

  const themes = {
    dark: {
      background: 'linear-gradient(135deg, #0f0f23, #1a1a2e)',
      surface: 'rgba(255,255,255,0.05)',
      surfaceBorder: 'rgba(100,255,218,0.2)',
      text: '#ffffff',
      textSecondary: '#b0bec5',
      primary: '#64ffda',
      secondary: '#7c4dff',
      cardBg: 'rgba(15,15,35,0.9)'
    },
    light: {
      background: 'linear-gradient(135deg, #f8f9fa, #e9ecef)',
      surface: 'rgba(255,255,255,0.9)',
      surfaceBorder: 'rgba(0,0,0,0.1)',
      text: '#212529',
      textSecondary: '#6c757d',
      primary: '#0056cc',
      secondary: '#4400cc',
      cardBg: '#ffffff'
    },
    neon: {
      background: 'linear-gradient(135deg, #000000, #1a0033)',
      surface: 'rgba(255,0,128,0.1)',
      surfaceBorder: 'rgba(255,0,128,0.5)',
      text: '#ff0080',
      textSecondary: '#ff69b4',
      primary: '#ff0080',
      secondary: '#00ff7f',
      cardBg: 'rgba(0,0,0,0.8)'
    }
  };

  const currentTheme = themes[theme];

  return (
    <div style={{
      minHeight: '100vh',
      background: currentTheme.background,
      color: currentTheme.text,
      padding: '3rem 2rem',
      transition: 'all 0.5s ease'
    }}>
      {/* Theme Toggle */}
      <div style={{textAlign: 'center', marginBottom: '3rem'}}>
        <h1 style={{
          fontSize: '3rem',
          background: `linear-gradient(45deg, ${currentTheme.primary}, ${currentTheme.secondary})`,
          WebkitBackgroundClip: 'text',
          WebkitTextFillColor: 'transparent',
          marginBottom: '2rem'
        }}>
          Dark Mode & Theming
        </h1>

        <div style={{display: 'flex', gap: '1rem', justifyContent: 'center', flexWrap: 'wrap'}}>
          <button
            onClick={() => setTheme('dark')}
            style={{
              padding: '0.75rem 2rem',
              borderRadius: '0.75rem',
              border: `2px solid ${theme === 'dark' ? currentTheme.primary : currentTheme.surfaceBorder}`,
              background: theme === 'dark' ? currentTheme.primary : 'transparent',
              color: theme === 'dark' ? '#0f0f23' : currentTheme.text,
              fontWeight: 600,
              cursor: 'pointer',
              transition: 'all 0.3s ease'
            }}
          >
            Dark
          </button>

          <button
            onClick={() => setTheme('light')}
            style={{
              padding: '0.75rem 2rem',
              borderRadius: '0.75rem',
              border: `2px solid ${theme === 'light' ? currentTheme.primary : currentTheme.surfaceBorder}`,
              background: theme === 'light' ? currentTheme.primary : 'transparent',
              color: theme === 'light' ? '#ffffff' : currentTheme.text,
              fontWeight: 600,
              cursor: 'pointer',
              transition: 'all 0.3s ease'
            }}
          >
            Light
          </button>

          <button
            onClick={() => setTheme('neon')}
            style={{
              padding: '0.75rem 2rem',
              borderRadius: '0.75rem',
              border: `2px solid ${theme === 'neon' ? currentTheme.primary : currentTheme.surfaceBorder}`,
              background: theme === 'neon' ? currentTheme.primary : 'transparent',
              color: theme === 'neon' ? '#000000' : currentTheme.text,
              fontWeight: 600,
              cursor: 'pointer',
              transition: 'all 0.3s ease'
            }}
          >
            Neon
          </button>
        </div>
      </div>

      {/* CSS Variables Example */}
      <section style={{maxWidth: '1200px', margin: '0 auto 3rem', background: currentTheme.surface, padding: '2rem', borderRadius: '1rem', border: `1px solid ${currentTheme.surfaceBorder}`, transition: 'all 0.5s ease'}}>
        <h2 style={{color: currentTheme.primary, marginBottom: '1.5rem'}}>CSS Custom Properties</h2>
        <pre style={{background: 'rgba(0,0,0,0.3)', padding: '1.5rem', borderRadius: '0.75rem', overflow: 'auto', color: currentTheme.textSecondary, fontSize: '0.875rem'}}>
{`:root {
  --color-bg: ${currentTheme.background};
  --color-surface: ${currentTheme.surface};
  --color-text: ${currentTheme.text};
  --color-primary: ${currentTheme.primary};
  --color-secondary: ${currentTheme.secondary};
}

[data-theme="dark"] {
  --color-bg: #0f0f23;
  --color-text: #ffffff;
}

[data-theme="light"] {
  --color-bg: #ffffff;
  --color-text: #212529;
}`}
        </pre>
      </section>

      {/* Component Examples */}
      <section style={{maxWidth: '1200px', margin: '0 auto 3rem'}}>
        <h2 style={{color: currentTheme.primary, marginBottom: '2rem', textAlign: 'center'}}>Themed Components</h2>

        <div style={{display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '2rem'}}>
          <div style={{background: currentTheme.cardBg, padding: '2rem', borderRadius: '1rem', border: `1px solid ${currentTheme.surfaceBorder}`, transition: 'all 0.5s ease'}}>
            <div style={{fontSize: '3rem', marginBottom: '1rem', textAlign: 'center'}}>↯</div>
            <h3 style={{color: currentTheme.primary, marginBottom: '0.75rem', textAlign: 'center', fontSize: '1.5rem'}}>Energy System</h3>
            <p style={{color: currentTheme.textSecondary, lineHeight: 1.6, textAlign: 'center'}}>
              Quantum energy management in the metropolis
            </p>
            <div style={{marginTop: '1.5rem', width: '100%', height: '8px', background: currentTheme.surface, borderRadius: '4px', overflow: 'hidden'}}>
              <div style={{width: '78%', height: '100%', background: `linear-gradient(90deg, ${currentTheme.primary}, ${currentTheme.secondary})`, borderRadius: '4px'}}></div>
            </div>
          </div>

          <div style={{background: currentTheme.cardBg, padding: '2rem', borderRadius: '1rem', border: `1px solid ${currentTheme.surfaceBorder}`, transition: 'all 0.5s ease'}}>
            <div style={{fontSize: '3rem', marginBottom: '1rem', textAlign: 'center'}}>◆</div>
            <h3 style={{color: currentTheme.primary, marginBottom: '0.75rem', textAlign: 'center', fontSize: '1.5rem'}}>AI Core</h3>
            <p style={{color: currentTheme.textSecondary, lineHeight: 1.6, textAlign: 'center'}}>
              Next-generation artificial intelligence
            </p>
            <div style={{marginTop: '1.5rem', width: '100%', height: '8px', background: currentTheme.surface, borderRadius: '4px', overflow: 'hidden'}}>
              <div style={{width: '92%', height: '100%', background: `linear-gradient(90deg, ${currentTheme.primary}, ${currentTheme.secondary})`, borderRadius: '4px'}}></div>
            </div>
          </div>

          <div style={{background: currentTheme.cardBg, padding: '2rem', borderRadius: '1rem', border: `1px solid ${currentTheme.surfaceBorder}`, transition: 'all 0.5s ease'}}>
            <div style={{fontSize: '3rem', marginBottom: '1rem', textAlign: 'center'}}>◆</div>
            <h3 style={{color: currentTheme.primary, marginBottom: '0.75rem', textAlign: 'center', fontSize: '1.5rem'}}>Network</h3>
            <p style={{color: currentTheme.textSecondary, lineHeight: 1.6, textAlign: 'center'}}>
              Global quantum network
            </p>
            <div style={{marginTop: '1.5rem', width: '100%', height: '8px', background: currentTheme.surface, borderRadius: '4px', overflow: 'hidden'}}>
              <div style={{width: '65%', height: '100%', background: `linear-gradient(90deg, ${currentTheme.primary}, ${currentTheme.secondary})`, borderRadius: '4px'}}></div>
            </div>
          </div>
        </div>
      </section>

      {/* System Preference Detection */}
      <section style={{maxWidth: '1200px', margin: '0 auto 3rem', background: currentTheme.surface, padding: '2rem', borderRadius: '1rem', border: `1px solid ${currentTheme.surfaceBorder}`, transition: 'all 0.5s ease'}}>
        <h2 style={{color: currentTheme.primary, marginBottom: '1.5rem'}}>System Preference Detection</h2>
        <pre style={{background: 'rgba(0,0,0,0.3)', padding: '1.5rem', borderRadius: '0.75rem', overflow: 'auto', color: currentTheme.textSecondary, fontSize: '0.875rem'}}>
{`@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #0f0f23;
    --color-text: #ffffff;
  }
}

@media (prefers-color-scheme: light) {
  :root {
    --color-bg: #ffffff;
    --color-text: #212529;
  }
}`}
        </pre>
      </section>
    </div>
  );
}

Docs entries used in this lesson

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Which CSS properties are the most performant to animate (do not trigger reflow), as shown in the lesson?

  2. 2. What is the @keyframes CSS rule used for?

Hands-on tasks in the game

  • Horizontal ordering

    Arrange the CSS transition declaration syntax (single line):

Useful articles