Kurs Next.js · Moduł 3: Stylowanie i optymalizacja wizualna
Animacje i przejścia w interfejsie użytkownika w Next.js 16
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ć:
transformopacityfilter
Unikaj animowania:
width,heighttop,left,right,bottommargin,padding
2. Używaj will-change z rozwagą
.animated-element {
will-change: transform, opacity;
/* Reszta stylów */
}Uwaga: Nadużywanie
will-changemoż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
- Celowość: Każda animacja powinna mieć cel - czy to informacyjny, czy nawigacyjny
- Subtelność: W większości przypadków, subtelne animacje są lepsze niż przesadne
- Spójność: Utrzymuj spójny język animacji w całej aplikacji
- Wydajność: Optymalizuj animacje dla lepszej wydajności
- Dostępność: Respektuj preferencje użytkowników dotyczące zredukowanych animacji
- Responsywność: Dostosowuj animacje do różnych rozmiarów ekranów
- 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:
- Wybieraj odpowiednie narzędzia - od prostego CSS po zaawansowane biblioteki jak Framer Motion
- Pamiętaj o wydajności - optymalizuj animacje dla płynności i niskiego zużycia zasobów
- Zachowaj dostępność - szanuj preferencje użytkowników dotyczące zredukowanych animacji
- Utrzymuj spójność - stwórz język animacji zgodny z tożsamością Twojej aplikacji
- 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. Które właściwości CSS są najwydajniejsze do animacji (nie wywołują reflow), jak pokazano w lekcji?
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):