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 15.
Zanim przejdziemy do konkretnych implementacji, warto poznać różne kategorie animacji występujących w interfejsach webowych:
Subtelne animacje reagujące na działania użytkownika, np.:
Płynne przejścia między różnymi stanami komponentów:
Animacje towarzyszące zmianie stron lub widoków:
Bardziej złożone animacje przedstawiające historię lub proces:
W ekosystemie React i Next.js mamy do dyspozycji wiele metod implementacji animacji:
Najprostszy i najbardziej wydajny sposób animowania prostych zmian:
1/* app/globals.css */
2.button {
3 background-color: #3a86ff;
4 color: white;
5 padding: 8px 16px;
6 border-radius: 4px;
7 transition: transform 0.2s ease, background-color 0.2s ease;
8}
9
10.button:hover {
11 transform: translateY(-2px);
12 background-color: #2563eb;
13}Dla bardziej złożonych animacji, które mają określone cykle:
1/* app/globals.css */
2@keyframes fadeIn {
3 from {
4 opacity: 0;
5 transform: translateY(20px);
6 }
7 to {
8 opacity: 1;
9 transform: translateY(0);
10 }
11}
12
13.card {
14 animation: fadeIn 0.5s ease forwards;
15}
16
17/* Sekwencyjne animacje dla listy elementów */
18.card:nth-child(1) { animation-delay: 0.1s; }
19.card:nth-child(2) { animation-delay: 0.2s; }
20.card:nth-child(3) { animation-delay: 0.3s; }Dla animacji zależnych od stanów React:
1// components/ExpandablePanel.tsx
2'use client';
3
4import { useState } from 'react';
5import styles from './ExpandablePanel.module.css';
6
7export function ExpandablePanel({ title, children }) {
8 const [isExpanded, setIsExpanded] = useState(false);
9
10 return (
11 <div className={styles.panel}>
12 <button
13 className={styles.header}
14 onClick={() => setIsExpanded(!isExpanded)}
15 >
16 <h3>{title}</h3>
17 <span className={`${styles.icon} ${isExpanded ? styles.iconExpanded : ''}`}>
18 ▼
19 </span>
20 </button>
21 <div className={`
22 ${styles.content}
23 ${isExpanded ? styles.contentExpanded : styles.contentCollapsed}
24 `}>
25 {children}
26 </div>
27 </div>
28 );
29}1/* components/ExpandablePanel.module.css */
2.panel {
3 border: 1px solid #ddd;
4 border-radius: 8px;
5 overflow: hidden;
6 margin-bottom: 16px;
7}
8
9.header {
10 display: flex;
11 justify-content: space-between;
12 align-items: center;
13 padding: 16px;
14 background-color: #f9f9f9;
15 cursor: pointer;
16 width: 100%;
17 text-align: left;
18 border: none;
19}
20
21.icon {
22 transition: transform 0.3s ease;
23}
24
25.iconExpanded {
26 transform: rotate(180deg);
27}
28
29.content {
30 padding: 0 16px;
31 transition: all 0.3s ease;
32 overflow: hidden;
33}
34
35.contentExpanded {
36 max-height: 500px; /* Warto dostosować do potrzeb */
37 padding: 16px;
38 opacity: 1;
39}
40
41.contentCollapsed {
42 max-height: 0;
43 padding-top: 0;
44 padding-bottom: 0;
45 opacity: 0;
46}Biblioteka ułatwiająca zarządzanie przejściami elementów w React:
1npm install react-transition-group1// components/Notification.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5import { CSSTransition } from 'react-transition-group';
6import styles from './Notification.module.css';
7
8interface NotificationProps {
9 message: string;
10 type?: 'success' | 'error' | 'info';
11 duration?: number;
12}
13
14export function Notification({ message, type = 'info', duration = 3000 }: NotificationProps) {
15 const [show, setShow] = useState(true);
16
17 useEffect(() => {
18 const timer = setTimeout(() => {
19 setShow(false);
20 }, duration);
21
22 return () => clearTimeout(timer);
23 }, [duration]);
24
25 return (
26 <CSSTransition
27 in={show}
28 timeout={300}
29 classNames={{
30 enter: styles.notificationEnter,
31 enterActive: styles.notificationEnterActive,
32 exit: styles.notificationExit,
33 exitActive: styles.notificationExitActive,
34 }}
35 unmountOnExit
36 >
37 <div className={`${styles.notification} ${styles[type]}`}>
38 {message}
39 </div>
40 </CSSTransition>
41 );
42}1/* components/Notification.module.css */
2.notification {
3 position: fixed;
4 bottom: 20px;
5 right: 20px;
6 padding: 16px;
7 border-radius: 8px;
8 color: white;
9 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
10 z-index: 1000;
11}
12
13.success {
14 background-color: #10b981;
15}
16
17.error {
18 background-color: #ef4444;
19}
20
21.info {
22 background-color: #3b82f6;
23}
24
25.notificationEnter {
26 opacity: 0;
27 transform: translateY(20px);
28}
29
30.notificationEnterActive {
31 opacity: 1;
32 transform: translateY(0);
33 transition: opacity 300ms, transform 300ms;
34}
35
36.notificationExit {
37 opacity: 1;
38}
39
40.notificationExitActive {
41 opacity: 0;
42 transform: translateY(20px);
43 transition: opacity 300ms, transform 300ms;
44}Framer Motion to potężna biblioteka animacji dla React, która upraszcza tworzenie złożonych animacji:
1npm install framer-motion1// components/Card.tsx
2'use client';
3
4import { motion } from 'framer-motion';
5import styles from './Card.module.css';
6
7interface CardProps {
8 title: string;
9 description: string;
10 imageUrl: string;
11 index: number;
12}
13
14export function Card({ title, description, imageUrl, index }: CardProps) {
15 return (
16 <motion.div
17 className={styles.card}
18 initial={{ opacity: 0, y: 20 }}
19 animate={{ opacity: 1, y: 0 }}
20 transition={{
21 duration: 0.5,
22 delay: index * 0.1,
23 ease: "easeOut"
24 }}
25 whileHover={{
26 scale: 1.05,
27 boxShadow: "0 10px 30px rgba(0, 0, 0, 0.15)"
28 }}
29 >
30 <div className={styles.imageContainer}>
31 <img src={imageUrl} alt={title} />
32 </div>
33 <div className={styles.content}>
34 <h3>{title}</h3>
35 <p>{description}</p>
36 </div>
37 </motion.div>
38 );
39}Next.js pozwala na łatwe łączenie CSS Modules z komponentami:
1// components/Button.tsx
2'use client';
3
4import { useState } from 'react';
5import styles from './Button.module.css';
6
7interface ButtonProps {
8 children: React.ReactNode;
9 onClick?: () => void;
10}
11
12export function Button({ children, onClick }: ButtonProps) {
13 const [isPressed, setIsPressed] = useState(false);
14
15 const handleClick = () => {
16 setIsPressed(true);
17 setTimeout(() => setIsPressed(false), 200);
18 onClick && onClick();
19 };
20
21 return (
22 <button
23 className={`${styles.button} ${isPressed ? styles.pressed : ''}`}
24 onClick={handleClick}
25 >
26 {children}
27 </button>
28 );
29}1/* components/Button.module.css */
2.button {
3 background-color: #3a86ff;
4 color: white;
5 border: none;
6 border-radius: 4px;
7 padding: 10px 20px;
8 font-size: 16px;
9 cursor: pointer;
10 transition: all 0.2s ease;
11 position: relative;
12 overflow: hidden;
13}
14
15.button:hover {
16 background-color: #2563eb;
17}
18
19.button:before {
20 content: '';
21 position: absolute;
22 top: 50%;
23 left: 50%;
24 width: 0;
25 height: 0;
26 background-color: rgba(255, 255, 255, 0.3);
27 border-radius: 50%;
28 transform: translate(-50%, -50%);
29 transition: width 0.4s ease-out, height 0.4s ease-out;
30}
31
32.button:hover:before {
33 width: 300px;
34 height: 300px;
35}
36
37.pressed {
38 transform: scale(0.98);
39}Next.js 15 wprowadza ulepszony system routingu, który pozwala na implementację płynnych przejść między stronami.
1// app/layout.tsx
2export default function RootLayout({
3 children,
4}: {
5 children: React.ReactNode;
6}) {
7 return (
8 <html lang="pl">
9 <body>
10 <main className="page-transition">
11 {children}
12 </main>
13 </body>
14 </html>
15 );
16}1/* app/globals.css */
2.page-transition {
3 animation: fadeIn 0.5s ease-in-out;
4}
5
6@keyframes fadeIn {
7 from {
8 opacity: 0;
9 transform: translateY(20px);
10 }
11 to {
12 opacity: 1;
13 transform: translateY(0);
14 }
15}1// components/PageTransition.tsx
2'use client';
3
4import { motion } from 'framer-motion';
5
6const variants = {
7 hidden: { opacity: 0, x: -200, y: 0 },
8 enter: { opacity: 1, x: 0, y: 0 },
9 exit: { opacity: 0, x: 0, y: -100 },
10};
11
12export function PageTransition({ children }: { children: React.ReactNode }) {
13 return (
14 <motion.div
15 variants={variants}
16 initial="hidden"
17 animate="enter"
18 exit="exit"
19 transition={{ type: 'linear', duration: 0.5 }}
20 >
21 {children}
22 </motion.div>
23 );
24}1// app/page.tsx
2import { PageTransition } from '@/components/PageTransition';
3
4export default function HomePage() {
5 return (
6 <PageTransition>
7 <h1>Witaj w Metropolis Quantum</h1>
8 {/* Reszta zawartości */}
9 </PageTransition>
10 );
11}1// components/Navigation.tsx
2'use client';
3
4import { useState } from 'react';
5import { useRouter } from 'next/navigation';
6import Link from 'next/link';
7import styles from './Navigation.module.css';
8
9export function Navigation() {
10 const router = useRouter();
11 const [isNavigating, setIsNavigating] = useState(false);
12
13 const handleNavigate = (href: string) => {
14 setIsNavigating(true);
15 setTimeout(() => {
16 router.push(href);
17 }, 500); // Daj czas na animację wyjścia
18 };
19
20 return (
21 <nav className={`${styles.nav} ${isNavigating ? styles.navigating : ''}`}>
22 <ul>
23 <li>
24 <a onClick={() => handleNavigate('/')}>Strona główna</a>
25 </li>
26 <li>
27 <a onClick={() => handleNavigate('/about')}>O nas</a>
28 </li>
29 <li>
30 <a onClick={() => handleNavigate('/services')}>Usługi</a>
31 </li>
32 <li>
33 <a onClick={() => handleNavigate('/contact')}>Kontakt</a>
34 </li>
35 </ul>
36 </nav>
37 );
38}1/* components/Navigation.module.css */
2.nav {
3 transition: opacity 0.5s ease;
4}
5
6.navigating {
7 opacity: 0.5;
8 pointer-events: none;
9}Framer Motion jest szczególnie przydatny do tworzenia złożonych, interaktywnych animacji UI. Przyjrzyjmy się kilku przykładom:
1// components/MobileMenu.tsx
2'use client';
3
4import { useState } from 'react';
5import { motion, AnimatePresence } from 'framer-motion';
6import Link from 'next/link';
7import styles from './MobileMenu.module.css';
8
9export function MobileMenu() {
10 const [isOpen, setIsOpen] = useState(false);
11
12 const menuVariants = {
13 closed: {
14 opacity: 0,
15 x: '100%',
16 transition: {
17 type: 'spring',
18 stiffness: 400,
19 damping: 40,
20 },
21 },
22 open: {
23 opacity: 1,
24 x: 0,
25 transition: {
26 type: 'spring',
27 stiffness: 400,
28 damping: 40,
29 staggerChildren: 0.1,
30 delayChildren: 0.2,
31 },
32 },
33 };
34
35 const menuItemVariants = {
36 closed: { opacity: 0, x: 50 },
37 open: { opacity: 1, x: 0 },
38 };
39
40 const buttonVariants = {
41 closed: { rotate: 0 },
42 open: { rotate: 90 },
43 };
44
45 return (
46 <div className={styles.mobileMenuContainer}>
47 <motion.button
48 className={styles.menuButton}
49 onClick={() => setIsOpen(!isOpen)}
50 variants={buttonVariants}
51 animate={isOpen ? 'open' : 'closed'}
52 transition={{ duration: 0.3 }}
53 >
54 <div className={styles.hamburger}>
55 <span className={`${styles.line} ${isOpen ? styles.line1Open : ''}`}></span>
56 <span className={`${styles.line} ${isOpen ? styles.line2Open : ''}`}></span>
57 <span className={`${styles.line} ${isOpen ? styles.line3Open : ''}`}></span>
58 </div>
59 </motion.button>
60
61 <AnimatePresence>
62 {isOpen && (
63 <motion.div
64 className={styles.menuOverlay}
65 variants={menuVariants}
66 initial="closed"
67 animate="open"
68 exit="closed"
69 >
70 <nav className={styles.mobileNav}>
71 <ul>
72 {['Home', 'About', 'Services', 'Contact'].map((item) => (
73 <motion.li key={item} variants={menuItemVariants}>
74 <Link href={item === 'Home' ? '/' : `/${item.toLowerCase()}`}>
75 {item}
76 </Link>
77 </motion.li>
78 ))}
79 </ul>
80 </nav>
81 </motion.div>
82 )}
83 </AnimatePresence>
84 </div>
85 );
86}1/* components/MobileMenu.module.css */
2.mobileMenuContainer {
3 position: fixed;
4 top: 20px;
5 right: 20px;
6 z-index: 1000;
7}
8
9.menuButton {
10 background-color: #3a86ff;
11 border: none;
12 width: 50px;
13 height: 50px;
14 border-radius: 50%;
15 display: flex;
16 align-items: center;
17 justify-content: center;
18 cursor: pointer;
19 z-index: 1001;
20 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
21}
22
23.hamburger {
24 width: 24px;
25 height: 18px;
26 position: relative;
27}
28
29.line {
30 position: absolute;
31 width: 100%;
32 height: 2px;
33 background-color: white;
34 transition: all 0.3s ease;
35}
36
37.line:nth-child(1) {
38 top: 0;
39}
40
41.line:nth-child(2) {
42 top: 8px;
43}
44
45.line:nth-child(3) {
46 top: 16px;
47}
48
49.line1Open {
50 top: 8px;
51 transform: rotate(45deg);
52}
53
54.line2Open {
55 opacity: 0;
56}
57
58.line3Open {
59 top: 8px;
60 transform: rotate(-45deg);
61}
62
63.menuOverlay {
64 position: fixed;
65 top: 0;
66 right: 0;
67 width: 300px;
68 height: 100vh;
69 background-color: white;
70 box-shadow: -5px 0 15px rgba(0, 0, 0, 0.1);
71 z-index: 1000;
72}
73
74.mobileNav {
75 padding: 80px 40px;
76}
77
78.mobileNav ul {
79 list-style: none;
80 padding: 0;
81 margin: 0;
82}
83
84.mobileNav li {
85 margin-bottom: 20px;
86}
87
88.mobileNav a {
89 font-size: 24px;
90 color: #333;
91 text-decoration: none;
92 transition: color 0.3s ease;
93}
94
95.mobileNav a:hover {
96 color: #3a86ff;
97}1// components/FlipCard.tsx
2'use client';
3
4import { useState } from 'react';
5import { motion } from 'framer-motion';
6import styles from './FlipCard.module.css';
7
8interface FlipCardProps {
9 frontContent: React.ReactNode;
10 backContent: React.ReactNode;
11}
12
13export function FlipCard({ frontContent, backContent }: FlipCardProps) {
14 const [isFlipped, setIsFlipped] = useState(false);
15
16 const frontVariants = {
17 flipped: { rotateY: 180, opacity: 0 },
18 unflipped: { rotateY: 0, opacity: 1 }
19 };
20
21 const backVariants = {
22 flipped: { rotateY: 0, opacity: 1 },
23 unflipped: { rotateY: -180, opacity: 0 }
24 };
25
26 return (
27 <div
28 className={styles.flipCardContainer}
29 onClick={() => setIsFlipped(!isFlipped)}
30 >
31 <motion.div
32 className={styles.flipCardFront}
33 variants={frontVariants}
34 animate={isFlipped ? 'flipped' : 'unflipped'}
35 transition={{ duration: 0.6, type: 'spring', stiffness: 300, damping: 20 }}
36 >
37 {frontContent}
38 </motion.div>
39
40 <motion.div
41 className={styles.flipCardBack}
42 variants={backVariants}
43 animate={isFlipped ? 'flipped' : 'unflipped'}
44 transition={{ duration: 0.6, type: 'spring', stiffness: 300, damping: 20 }}
45 >
46 {backContent}
47 </motion.div>
48 </div>
49 );
50}1/* components/FlipCard.module.css */
2.flipCardContainer {
3 width: 300px;
4 height: 400px;
5 perspective: 1000px;
6 cursor: pointer;
7 position: relative;
8}
9
10.flipCardFront,
11.flipCardBack {
12 position: absolute;
13 width: 100%;
14 height: 100%;
15 backface-visibility: hidden;
16 border-radius: 16px;
17 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
18 display: flex;
19 flex-direction: column;
20 justify-content: center;
21 align-items: center;
22 padding: 20px;
23}
24
25.flipCardFront {
26 background-color: #f9f9f9;
27 color: #333;
28}
29
30.flipCardBack {
31 background-color: #3a86ff;
32 color: white;
33}1// components/ScrollProgressBar.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5import { motion, useScroll } from 'framer-motion';
6import styles from './ScrollProgressBar.module.css';
7
8export function ScrollProgressBar() {
9 const { scrollYProgress } = useScroll();
10
11 return (
12 <motion.div
13 className={styles.progressBar}
14 style={{ scaleX: scrollYProgress }}
15 />
16 );
17}1/* components/ScrollProgressBar.module.css */
2.progressBar {
3 position: fixed;
4 top: 0;
5 left: 0;
6 right: 0;
7 height: 5px;
8 background-color: #3a86ff;
9 transform-origin: 0%;
10 z-index: 1000;
11}Animacje mogą być obciążające dla przeglądarki, dlatego warto stosować metody optymalizacji:
Najlepiej animować:
transformopacityfilterUnikaj animowania:
width, heighttop, left, right, bottommargin, paddingwill-change z rozwagą1.animated-element {
2 will-change: transform, opacity;
3 /* Reszta stylów */
4}Uwaga: Nadużywanie
może pogorszyć wydajność.will-change
1// components/AnimatedSection.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5import dynamic from 'next/dynamic';
6
7// Lazy loading Framer Motion
8const motion = dynamic(() => import('framer-motion').then((mod) => mod.motion), {
9 ssr: false,
10 loading: () => <div>Loading...</div>,
11});
12
13export function AnimatedSection({ children }) {
14 const [isClient, setIsClient] = useState(false);
15
16 useEffect(() => {
17 setIsClient(true);
18 }, []);
19
20 if (!isClient) {
21 return <div>{children}</div>;
22 }
23
24 return (
25 <motion.div
26 initial={{ opacity: 0 }}
27 animate={{ opacity: 1 }}
28 transition={{ duration: 0.5 }}
29 >
30 {children}
31 </motion.div>
32 );
33}1.accelerated {
2 transform: translateZ(0); /* Włącza akcelerację GPU */
3}Pamiętaj, że niektórzy użytkownicy mogą preferować zredukowane animacje, np. ze względu na zaburzenia przedsionkowe.
1@media (prefers-reduced-motion: reduce) {
2 /* Wyłącz lub uprość animacje */
3 * {
4 animation-duration: 0.01ms !important;
5 animation-iteration-count: 1 !important;
6 transition-duration: 0.01ms !important;
7 scroll-behavior: auto !important;
8 }
9}1// components/AnimationToggle.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5import styles from './AnimationToggle.module.css';
6
7export function AnimationToggle() {
8 const [areAnimationsEnabled, setAreAnimationsEnabled] = useState(true);
9
10 useEffect(() => {
11 // Sprawdź preferencje systemowe
12 const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
13 setAreAnimationsEnabled(!prefersReducedMotion);
14
15 // Sprawdź zapisane preferencje użytkownika
16 const savedPreference = localStorage.getItem('animations-enabled');
17 if (savedPreference !== null) {
18 setAreAnimationsEnabled(savedPreference === 'true');
19 }
20 }, []);
21
22 const toggleAnimations = () => {
23 const newValue = !areAnimationsEnabled;
24 setAreAnimationsEnabled(newValue);
25 document.documentElement.classList.toggle('reduce-animations', !newValue);
26 localStorage.setItem('animations-enabled', String(newValue));
27 };
28
29 return (
30 <button
31 className={styles.toggleButton}
32 onClick={toggleAnimations}
33 aria-pressed={!areAnimationsEnabled}
34 >
35 {areAnimationsEnabled ? 'Wyłącz animacje' : 'Włącz animacje'}
36 </button>
37 );
38}1/* app/globals.css */
2.reduce-animations * {
3 animation-duration: 0.01ms !important;
4 animation-iteration-count: 1 !important;
5 transition-duration: 0.01ms !important;
6 scroll-behavior: auto !important;
7}1// components/FadeInSection.tsx
2'use client';
3
4import { useRef, useEffect, useState } from 'react';
5import styles from './FadeInSection.module.css';
6
7export function FadeInSection({ children }) {
8 const [isVisible, setIsVisible] = useState(false);
9 const ref = useRef<HTMLDivElement>(null);
10
11 useEffect(() => {
12 const observer = new IntersectionObserver(
13 ([entry]) => {
14 setIsVisible(entry.isIntersecting);
15 },
16 {
17 root: null,
18 rootMargin: '0px',
19 threshold: 0.1,
20 }
21 );
22
23 if (ref.current) {
24 observer.observe(ref.current);
25 }
26
27 return () => {
28 if (ref.current) {
29 observer.unobserve(ref.current);
30 }
31 };
32 }, []);
33
34 return (
35 <div
36 ref={ref}
37 className={`${styles.fadeInSection} ${isVisible ? styles.isVisible : ''}`}
38 >
39 {children}
40 </div>
41 );
42}1/* components/FadeInSection.module.css */
2.fadeInSection {
3 opacity: 0;
4 transform: translateY(30px);
5 transition: opacity 1s ease, transform 1s ease;
6}
7
8.isVisible {
9 opacity: 1;
10 transform: translateY(0);
11}1// components/ScrollAnimation.tsx
2'use client';
3
4import { useRef } from 'react';
5import { motion, useScroll, useTransform } from 'framer-motion';
6import styles from './ScrollAnimation.module.css';
7
8export function ScrollAnimation() {
9 const ref = useRef<HTMLDivElement>(null);
10
11 const { scrollYProgress } = useScroll({
12 target: ref,
13 offset: ["start end", "end start"]
14 });
15
16 const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [0, 1, 0]);
17 const scale = useTransform(scrollYProgress, [0, 0.5, 1], [0.8, 1, 0.8]);
18 const rotate = useTransform(scrollYProgress, [0, 0.5, 1], [-15, 0, 15]);
19
20 return (
21 <div ref={ref} className={styles.container}>
22 <motion.div
23 className={styles.animatedBox}
24 style={{ opacity, scale, rotate }}
25 >
26 <h2>Animacja podczas przewijania</h2>
27 <p>Ta sekcja animuje się w zależności od pozycji przewijania.</p>
28 </motion.div>
29 </div>
30 );
31}1/* components/ScrollAnimation.module.css */
2.container {
3 height: 100vh;
4 display: flex;
5 justify-content: center;
6 align-items: center;
7}
8
9.animatedBox {
10 padding: 40px;
11 background-color: #3a86ff;
12 color: white;
13 border-radius: 16px;
14 max-width: 500px;
15 text-align: center;
16}1// components/DataTable.tsx
2'use client';
3
4import { useState } from 'react';
5import { motion, AnimatePresence } from 'framer-motion';
6import styles from './DataTable.module.css';
7
8interface DataItem {
9 id: string;
10 name: string;
11 value: number;
12 category: string;
13}
14
15interface DataTableProps {
16 data: DataItem[];
17}
18
19export function DataTable({ data }: DataTableProps) {
20 const [sortField, setSortField] = useState<keyof DataItem>('name');
21 const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
22
23 const handleSort = (field: keyof DataItem) => {
24 if (field === sortField) {
25 setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
26 } else {
27 setSortField(field);
28 setSortDirection('asc');
29 }
30 };
31
32 const sortedData = [...data].sort((a, b) => {
33 if (a[sortField] < b[sortField]) return sortDirection === 'asc' ? -1 : 1;
34 if (a[sortField] > b[sortField]) return sortDirection === 'asc' ? 1 : -1;
35 return 0;
36 });
37
38 return (
39 <div className={styles.tableContainer}>
40 <table className={styles.table}>
41 <thead>
42 <tr>
43 <th onClick={() => handleSort('name')}>
44 Nazwa {sortField === 'name' && (sortDirection === 'asc' ? '↑' : '↓')}
45 </th>
46 <th onClick={() => handleSort('value')}>
47 Wartość {sortField === 'value' && (sortDirection === 'asc' ? '↑' : '↓')}
48 </th>
49 <th onClick={() => handleSort('category')}>
50 Kategoria {sortField === 'category' && (sortDirection === 'asc' ? '↑' : '↓')}
51 </th>
52 </tr>
53 </thead>
54 <motion.tbody layout>
55 <AnimatePresence>
56 {sortedData.map((item) => (
57 <motion.tr
58 key={item.id}
59 initial={{ opacity: 0, y: 20 }}
60 animate={{ opacity: 1, y: 0 }}
61 exit={{ opacity: 0, y: -20 }}
62 transition={{ duration: 0.3 }}
63 layout
64 >
65 <td>{item.name}</td>
66 <td>{item.value}</td>
67 <td>{item.category}</td>
68 </motion.tr>
69 ))}
70 </AnimatePresence>
71 </motion.tbody>
72 </table>
73 </div>
74 );
75}1// components/ChatInterface.tsx
2'use client';
3
4import { useState } from 'react';
5import { motion, AnimatePresence } from 'framer-motion';
6import styles from './ChatInterface.module.css';
7
8interface Message {
9 id: number;
10 text: string;
11 sender: 'user' | 'assistant';
12 timestamp: Date;
13}
14
15export function ChatInterface() {
16 const [messages, setMessages] = useState<Message[]>([]);
17 const [inputText, setInputText] = useState('');
18
19 const handleSend = () => {
20 if (!inputText.trim()) return;
21
22 // Dodaj wiadomość użytkownika
23 const userMessage: Message = {
24 id: Date.now(),
25 text: inputText,
26 sender: 'user',
27 timestamp: new Date(),
28 };
29
30 setMessages([...messages, userMessage]);
31 setInputText('');
32
33 // Symuluj odpowiedź asystenta
34 setTimeout(() => {
35 const assistantMessage: Message = {
36 id: Date.now() + 1,
37 text: `Otrzymałem Twoją wiadomość: "${inputText}"`,
38 sender: 'assistant',
39 timestamp: new Date(),
40 };
41
42 setMessages((prev) => [...prev, assistantMessage]);
43 }, 1000);
44 };
45
46 return (
47 <div className={styles.chatContainer}>
48 <div className={styles.chatMessages}>
49 <AnimatePresence>
50 {messages.map((message) => (
51 <motion.div
52 key={message.id}
53 className={`${styles.message} ${styles[message.sender]}`}
54 initial={{ opacity: 0, y: 20, scale: 0.9 }}
55 animate={{ opacity: 1, y: 0, scale: 1 }}
56 transition={{ duration: 0.3 }}
57 >
58 <div className={styles.messageContent}>
59 {message.text}
60 </div>
61 <div className={styles.timestamp}>
62 {message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
63 </div>
64 </motion.div>
65 ))}
66 </AnimatePresence>
67 </div>
68
69 <div className={styles.chatInput}>
70 <input
71 type="text"
72 value={inputText}
73 onChange={(e) => setInputText(e.target.value)}
74 onKeyPress={(e) => e.key === 'Enter' && handleSend()}
75 placeholder="Napisz wiadomość..."
76 />
77 <button onClick={handleSend}>Wyślij</button>
78 </div>
79 </div>
80 );
81}1// components/ThemeToggle.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5import { motion } from 'framer-motion';
6import styles from './ThemeToggle.module.css';
7
8export function ThemeToggle() {
9 const [isDarkMode, setIsDarkMode] = useState(false);
10
11 useEffect(() => {
12 // Sprawdź preferencje systemowe lub zapisane wcześniej ustawienia
13 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
14 const savedTheme = localStorage.getItem('theme');
15 const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
16
17 setIsDarkMode(initialTheme === 'dark');
18 document.documentElement.classList.toggle('dark-theme', initialTheme === 'dark');
19 }, []);
20
21 const toggleTheme = () => {
22 const newMode = !isDarkMode;
23 setIsDarkMode(newMode);
24 document.documentElement.classList.toggle('dark-theme', newMode);
25 localStorage.setItem('theme', newMode ? 'dark' : 'light');
26 };
27
28 return (
29 <button
30 className={styles.themeToggle}
31 onClick={toggleTheme}
32 aria-label={isDarkMode ? 'Przełącz na jasny motyw' : 'Przełącz na ciemny motyw'}
33 >
34 <motion.div
35 className={styles.toggleTrack}
36 animate={{ backgroundColor: isDarkMode ? '#3a86ff' : '#ccc' }}
37 >
38 <motion.div
39 className={styles.toggleThumb}
40 animate={{
41 x: isDarkMode ? 22 : 0,
42 backgroundColor: isDarkMode ? '#121212' : '#fff',
43 }}
44 transition={{ type: 'spring', stiffness: 500, damping: 30 }}
45 >
46 <motion.div
47 className={styles.iconContainer}
48 animate={{ opacity: isDarkMode ? 1 : 0 }}
49 >
50 {/* Ikona księżyca */}
51 🌙
52 </motion.div>
53 <motion.div
54 className={styles.iconContainer}
55 animate={{ opacity: isDarkMode ? 0 : 1 }}
56 >
57 {/* Ikona słońca */}
58 ☀️
59 </motion.div>
60 </motion.div>
61 </motion.div>
62 </button>
63 );
64}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 15. 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:
W następnym rozdziale przyjrzymy się zaawansowanym funkcjom wydajnościowym Next.js 15, które pozwolą Twojej aplikacji działać płynnie i szybko, podobnie jak najbardziej zaawansowane systemy w Metropolis Quantum.