Kurs Next.js · Moduł 3: Stylowanie i optymalizacja wizualna

Animacje i przejścia w interfejsie użytkownika w Next.js 16

18 min czytania ·
W tej lekcji10

W Metropolis Quantum nic nie stoi w miejscu. Pojazdy przemieszczają się płynnie między dzielnicami, holograficzne wyświetlacze zmieniają się z eleganckimi przejściami, a interaktywne elementy miasta reagują na mieszkańców subtelnymi, ale znaczącymi animacjami. Ta dynamika nie tylko zapewnia atrakcyjność wizualną, ale przede wszystkim poprawia użyteczność i intuicyjność miejskich systemów.

Podobnie w aplikacjach webowych - dobrze zaprojektowane animacje i przejścia mogą znacząco wpłynąć na doświadczenie użytkownika, czyniąc interfejs bardziej naturalnym, informacyjnym i przyjemnym w obsłudze. W tym rozdziale poznamy różne techniki implementacji animacji i przejść w aplikacjach Next.js 16.

Rodzaje animacji w interfejsach webowych

Zanim przejdziemy do konkretnych implementacji, warto poznać różne kategorie animacji występujących w interfejsach webowych:

1. Animacje mikro-interakcji

Subtelne animacje reagujące na działania użytkownika, np.:

  • Efekty najechania na przyciski
  • Animacje kliknięcia
  • Wskaźniki ładowania
  • Zmiany stanów formularzy

2. Animacje przejść między stanami

Płynne przejścia między różnymi stanami komponentów:

  • Rozwijane menu
  • Powiększanie/zmniejszanie elementów
  • Zmiany widoczności elementów
  • Przełączanie trybów (jasny/ciemny)

3. Animacje nawigacyjne

Animacje towarzyszące zmianie stron lub widoków:

  • Przejścia między stronami
  • Zmiana zakładek
  • Przewijanie strony
  • Efekty paralaksy

4. Animacje narracyjne

Bardziej złożone animacje przedstawiające historię lub proces:

  • Sekwencje onboardingowe
  • Ilustracje procesów
  • Animowane infografiki
  • Storytelling wizualny

Implementacja animacji w React i Next.js 16

W ekosystemie React i Next.js mamy do dyspozycji wiele metod implementacji animacji:

1. CSS i CSS transitions

Najprostszy i najbardziej wydajny sposób animowania prostych zmian:

/* 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

Dla bardziej złożonych animacji, które mają określone cykle:

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

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

/* Sekwencyjne animacje dla listy elementów */
.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 i useState

Dla animacji zależnych od stanów React:

// 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; /* Warto dostosować do potrzeb */
  padding: 16px;
  opacity: 1;
}

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

4. React Transition Group

Biblioteka ułatwiająca zarządzanie przejściami elementów w 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 to potężna biblioteka animacji dla React, która upraszcza tworzenie złożonych animacji:

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 z Next.js

Next.js pozwala na łatwe łączenie CSS Modules z komponentami:

// 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);
}

Animacje przejść między stronami w Next.js 16

Next.js 16 korzysta z React 19.2, więc oprócz klasycznych technik, takich jak animacje CSS czy Framer Motion, możesz użyć komponentu <ViewTransition> z Reacta. W App Routerze działa on bez żadnej konfiguracji, bo każda nawigacja jest przejściem (transition), a przeglądarki bez obsługi View Transitions API po prostu pokażą nową stronę bez animacji.

1. Podstawowe przejścia z użyciem 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. Zaawansowane przejścia z 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>Witaj w Metropolis Quantum</h1>
      {/* Reszta zawartości */}
    </PageTransition>
  );
}

3. Przejścia z użyciem 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); // Daj czas na animację wyjścia
  };

  return (
    <nav className={`${styles.nav} ${isNavigating ? styles.navigating : ''}`}>
      <ul>
        <li>
          <a onClick={() => handleNavigate('/')}>Strona główna</a>
        </li>
        <li>
          <a onClick={() => handleNavigate('/about')}>O nas</a>
        </li>
        <li>
          <a onClick={() => handleNavigate('/services')}>Usługi</a>
        </li>
        <li>
          <a onClick={() => handleNavigate('/contact')}>Kontakt</a>
        </li>
      </ul>
    </nav>
  );
}
/* components/Navigation.module.css */
.nav {
  transition: opacity 0.5s ease;
}

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

Implementacja złożonych animacji UI z Framer Motion

Framer Motion jest szczególnie przydatny do tworzenia złożonych, interaktywnych animacji UI. Przyjrzyjmy się kilku przykładom:

1. Animowane menu mobilne

// 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. Animowane karty z efektem przerzucania

// 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. Pasek postępu przy przewijaniu strony

// 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;
}

Optymalizacja wydajności animacji

Animacje mogą być obciążające dla przeglądarki, dlatego warto stosować metody optymalizacji:

1. Korzystaj z właściwości CSS, które nie powodują reflow

Najlepiej animować:

  • transform
  • opacity
  • filter

Unikaj animowania:

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

2. Używaj will-change z rozwagą

.animated-element {
  will-change: transform, opacity;
  /* Reszta stylów */
}

Uwaga: Nadużywanie will-change może pogorszyć wydajność.

3. Opóźnione ładowanie ciężkich bibliotek animacji

// 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. Używanie GPU do animacji

.accelerated {
  transform: translateZ(0); /* Włącza akcelerację GPU */
}

Dostępność animacji (accessibility)

Pamiętaj, że niektórzy użytkownicy mogą preferować zredukowane animacje, np. ze względu na zaburzenia przedsionkowe.

1. Respektowanie preferencji systemowych

@media (prefers-reduced-motion: reduce) {
  /* Wyłącz lub uprość animacje */
  * {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

2. Implementacja przełącznika animacji

// 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(() => {
    // Sprawdź preferencje systemowe
    const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    setAreAnimationsEnabled(!prefersReducedMotion);

    // Sprawdź zapisane preferencje użytkownika
    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 ? 'Wyłącz animacje' : 'Włącz animacje'}
    </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;
}

Animacje zależne od scroll-a

1. Podstawowa animacja podczas przewijania z 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. Zaawansowane animacje scroll z 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>Animacja podczas przewijania</h2>
        <p>Ta sekcja animuje się w zależności od pozycji przewijania.</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;
}

Przypadki użycia w różnych kontekstach

1. Animowane tabele danych

// 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')}>
              Nazwa {sortField === 'name' && (sortDirection === 'asc' ? '↑' : '↓')}
            </th>
            <th onClick={() => handleSort('value')}>
              Wartość {sortField === 'value' && (sortDirection === 'asc' ? '↑' : '↓')}
            </th>
            <th onClick={() => handleSort('category')}>
              Kategoria {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. Animowany interfejs czatu

// 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;

    // Dodaj wiadomość użytkownika
    const userMessage: Message = {
      id: Date.now(),
      text: inputText,
      sender: 'user',
      timestamp: new Date(),
    };

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

    // Symuluj odpowiedź asystenta
    setTimeout(() => {
      const assistantMessage: Message = {
        id: Date.now() + 1,
        text: `Otrzymałem Twoją wiadomość: "${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="Napisz wiadomość..."
        />
        <button onClick={handleSend}>Wyślij</button>
      </div>
    </div>
  );
}

3. Animowany wybór motywu

// 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(() => {
    // Sprawdź preferencje systemowe lub zapisane wcześniej ustawienia
    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 ? 'Przełącz na jasny motyw' : 'Przełącz na ciemny motyw'}
    >
      <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 }}
          >
            {/* Ikona księżyca */}
            ◐
          </motion.div>
          <motion.div
            className={styles.iconContainer}
            animate={{ opacity: isDarkMode ? 0 : 1 }}
          >
            {/* Ikona słońca */}
            ●
          </motion.div>
        </motion.div>
      </motion.div>
    </button>
  );
}

Najlepsze praktyki przy tworzeniu animacji

  1. Celowość: Każda animacja powinna mieć cel - czy to informacyjny, czy nawigacyjny
  2. Subtelność: W większości przypadków, subtelne animacje są lepsze niż przesadne
  3. Spójność: Utrzymuj spójny język animacji w całej aplikacji
  4. Wydajność: Optymalizuj animacje dla lepszej wydajności
  5. Dostępność: Respektuj preferencje użytkowników dotyczące zredukowanych animacji
  6. Responsywność: Dostosowuj animacje do różnych rozmiarów ekranów
  7. Testowanie: Testuj animacje na różnych urządzeniach i przeglądarkach

Podsumowanie

Animacje i przejścia to potężne narzędzia, które mogą znacząco wpłynąć na doświadczenia użytkowników Twojej aplikacji Next.js 16. Podobnie jak w Metropolis Quantum, gdzie płynne animacje holograficznych wyświetlaczy i systemów miejskich sprawiają, że technologia staje się bardziej intuicyjna i przyjazna, dobrze zaprojektowane animacje w Twojej aplikacji poprawiają użyteczność, zaangażowanie i ogólne wrażenia.

Kluczowe punkty:

  1. Wybieraj odpowiednie narzędzia - od prostego CSS po zaawansowane biblioteki jak Framer Motion
  2. Pamiętaj o wydajności - optymalizuj animacje dla płynności i niskiego zużycia zasobów
  3. Zachowaj dostępność - szanuj preferencje użytkowników dotyczące zredukowanych animacji
  4. Utrzymuj spójność - stwórz język animacji zgodny z tożsamością Twojej aplikacji
  5. Celowość ponad ozdobność - każda animacja powinna służyć konkretnej funkcji

W następnym rozdziale przyjrzymy się zaawansowanym funkcjom wydajnościowym Next.js 16, które pozwolą Twojej aplikacji działać płynnie i szybko, podobnie jak najbardziej zaawansowane systemy w Metropolis Quantum.

Kod do tej lekcji: 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'}}>
              Zarządzanie energią kwantową w metropolii
            </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'}}>
              Sztuczna inteligencja nowej generacji
            </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'}}>
              Globalna sieć kwantowa
            </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>
  );
}

Hasła z dokumentacji w tej lekcji

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Które właściwości CSS są najwydajniejsze do animacji (nie wywołują reflow), jak pokazano w lekcji?

  2. 2. Do czego służy reguła @keyframes w CSS?

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż składnię deklaracji CSS transition (jedna linia):

Przydatne artykuły