Kurs JavaScript i React · Moduł 14: Zaawansowane stylowanie
Responsive Design Patterns w React - Adaptacja do Każdej Przestrzeni
W tej lekcji7
Pilotze, Twój statek kosmiczny musi działać równie dobrze na małym ekranie nadręcznego terminala, co na wielkim monitorze centrum dowodzenia. W świecie React responsive design oznacza tworzenie komponentów, które elegancko dostosowują się do każdego rozmiaru ekranu -- od mobilnego urządzenia po ultraszeroki monitor.
Hook useMediaQuery -- responsywność w JavaScript
Czasem potrzebujesz zmienić nie tylko style, ale całą strukturę komponentu w zależności od rozmiaru ekranu. Wtedy przydaje się custom hook useMediaQuery:
1function useMediaQuery(query) {
2 const [matches, setMatches] = useState(
3 () => window.matchMedia(query).matches
4 );
5
6 useEffect(() => {
7 const mediaQuery = window.matchMedia(query);
8 const handler = (e) => setMatches(e.matches);
9
10 mediaQuery.addEventListener('change', handler);
11 return () => mediaQuery.removeEventListener('change', handler);
12 }, [query]);
13
14 return matches;
15}Użycie w komponencie:
1function MissionDashboard() {
2 const isMobile = useMediaQuery('(max-width: 767px)');
3 const isTablet = useMediaQuery('(min-width: 768px) and (max-width: 1023px)');
4
5 if (isMobile) return <MobileDashboard />;
6 if (isTablet) return <TabletDashboard />;
7 return <DesktopDashboard />;
8}Hook słucha na zmiany rozmiaru okna i zwraca true/false -- React automatycznie przerenderuje komponent gdy wartość się zmieni.
Mobile-first CSS w React
Podejście mobile-first oznacza: zaczynamy od stylów dla najmniejszych ekranów, a potem rozszerzamy dla większych. Tak jak projektowanie kokpitu od najprostszego terminala w górę:
1/* Bazowe style -- mobile */
2.grid {
3 display: grid;
4 grid-template-columns: 1fr;
5 gap: 16px;
6}
7
8/* Tablet -- rozszerzamy */
9@media (min-width: 768px) {
10 .grid {
11 grid-template-columns: repeat(2, 1fr);
12 }
13}
14
15/* Desktop -- jeszcze szerzej */
16@media (min-width: 1024px) {
17 .grid {
18 grid-template-columns: repeat(3, 1fr);
19 }
20}Responsywna kompozycja komponentów
Zamiast jednego ogromnego komponentu z wieloma warunkami, warto dzielić UI na mniejsze części, które można różnie komponować:
1function Sidebar({ items }) {
2 return (
3 <nav className="sidebar">
4 {items.map(item => (
5 <a key={item.id} href={item.href}>{item.label}</a>
6 ))}
7 </nav>
8 );
9}
10
11function BottomNav({ items }) {
12 return (
13 <nav className="bottom-nav">
14 {items.map(item => (
15 <a key={item.id} href={item.href}>{item.icon}</a>
16 ))}
17 </nav>
18 );
19}
20
21function Navigation({ items }) {
22 const isMobile = useMediaQuery('(max-width: 767px)');
23 return isMobile ? <BottomNav items={items} /> : <Sidebar items={items} />;
24}Na mobilce nawigacja jest na dole ekranu (jak w aplikacji), a na desktopie -- z boku jako sidebar. Ten sam zestaw danych, różna prezentacja.
Container Queries w React
Container queries to nowa, potężna funkcja CSS. Pozwalają stylować element na podstawie rozmiaru jego rodzica, a nie całego okna przeglądarki:
1.card-container {
2 container-type: inline-size;
3}
4
5@container (min-width: 400px) {
6 .card {
7 display: flex;
8 gap: 16px;
9 }
10}
11
12@container (min-width: 600px) {
13 .card {
14 grid-template-columns: 1fr 2fr;
15 }
16}W React ustawiamy container-type na rodzicu:
1function CardGrid({ children }) {
2 return (
3 <div style={{ containerType: 'inline-size' }}>
4 {children}
5 </div>
6 );
7}Container queries są świetne dla komponentów reużywanych -- karta misji może wyglądać inaczej w wąskim sidebarze i szerokim main content, bez wiedzy o rozmiarze okna.
Fluid Typography -- płynna typografia
Zamiast skokowo zmieniać rozmiar czcionki między breakpointami, można użyć clamp() dla płynnego skalowania:
1.heading {
2 /* Minimalnie 18px, optymalnie 4vw, maksymalnie 36px */
3 font-size: clamp(18px, 4vw, 36px);
4}
5
6.body-text {
7 font-size: clamp(14px, 2vw, 18px);
8 line-height: clamp(20px, 3vw, 28px);
9}W React można to zaimplementować jako system:
1const fluidType = {
2 heading: 'clamp(18px, 4vw, 36px)',
3 subheading: 'clamp(16px, 3vw, 24px)',
4 body: 'clamp(14px, 2vw, 18px)',
5 caption: 'clamp(11px, 1.5vw, 14px)',
6};
7
8function Heading({ children }) {
9 return <h1 style={{ fontSize: fluidType.heading }}>{children}</h1>;
10}Responsive Images w React
Obrazy to często najcięższe zasoby na stronie. React pozwala implementować responsywne obrazy:
1function ResponsiveImage({ src, alt, sizes }) {
2 return (
3 <img
4 src={src}
5 alt={alt}
6 srcSet={`${src}?w=400 400w, ${src}?w=800 800w, ${src}?w=1200 1200w`}
7 sizes={sizes || '(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'}
8 loading="lazy"
9 style={{ width: '100%', height: 'auto' }}
10 />
11 );
12}Atrybut srcSet mówi przeglądarce o dostępnych rozmiarach, a sizes -- który rozmiar użyć przy danym rozmiarze okna.
Kiedy CSS, kiedy JavaScript?
- Media queries w CSS -- gdy zmieniasz tylko style (kolory, rozmiary, układ)
- useMediaQuery w JS -- gdy zmieniasz strukturę komponentu (inne komponenty, inna logika)
- Container queries -- gdy komponent musi reagować na rozmiar swojego kontenera
- clamp() -- dla płynnego skalowania wartości między minimum a maksimum
Kod do tej lekcji: App.jsx
1import React, { useState, useEffect } from 'react';
2import './styles.css';
3
4// Hook useMediaQuery -- slucha na zmiany rozmiaru okna
5function useMediaQuery(query) {
6 const [matches, setMatches] = useState(
7 () => window.matchMedia(query).matches
8 );
9
10 useEffect(() => {
11 const mq = window.matchMedia(query);
12 const handler = (e) => setMatches(e.matches);
13 mq.addEventListener('change', handler);
14 return () => mq.removeEventListener('change', handler);
15 }, [query]);
16
17 return matches;
18}
19
20// Karta misji -- rozna zawartosc na mobile i desktop
21function MissionCard({ name, status, crew, description, stats }) {
22 const isMobile = useMediaQuery('(max-width: 500px)');
23
24 return (
25 <div className="mission-card">
26 <div className="card-header">
27 <h3 className="card-name">{name}</h3>
28 <span className={`badge badge-${status}`}>{status}</span>
29 </div>
30 <p className="card-desc">{description}</p>
31 {!isMobile && (
32 <div className="stats-row">
33 {stats.map(s => (
34 <div key={s.label} className="stat">
35 <span className="stat-value">{s.value}</span>
36 <span className="stat-label">{s.label}</span>
37 </div>
38 ))}
39 </div>
40 )}
41 <div className="card-footer">
42 <span className="crew">Zaloga: {crew}</span>
43 {!isMobile && <button className="btn">Szczegoly</button>}
44 </div>
45 </div>
46 );
47}
48
49// Nawigacja -- sidebar na desktop, bottom nav na mobile
50function Navigation({ items, active, onSelect }) {
51 const isMobile = useMediaQuery('(max-width: 500px)');
52
53 if (isMobile) {
54 return (
55 <nav className="bottom-nav">
56 {items.map(item => (
57 <button
58 key={item.id}
59 className={`nav-item ${active === item.id ? 'nav-active' : ''}`}
60 onClick={() => onSelect(item.id)}
61 >
62 {item.icon} {item.label}
63 </button>
64 ))}
65 </nav>
66 );
67 }
68
69 return (
70 <nav className="sidebar">
71 <h2 className="sidebar-title">Nawigacja</h2>
72 {items.map(item => (
73 <button
74 key={item.id}
75 className={`sidebar-item ${active === item.id ? 'sidebar-active' : ''}`}
76 onClick={() => onSelect(item.id)}
77 >
78 {item.icon} {item.label}
79 </button>
80 ))}
81 </nav>
82 );
83}
84
85const navItems = [
86 { id: 'missions', label: 'Misje', icon: '' },
87 { id: 'crew', label: 'Zaloga', icon: '' },
88 { id: 'systems', label: 'Systemy', icon: '' },
89];
90
91const missions = [
92 { name: 'Apollo X', status: 'active', crew: 4, description: 'Eksploracja Marsa',
93 stats: [{ label: 'Dystans', value: '225M km' }, { label: 'Czas', value: '7 mies.' }, { label: 'Postep', value: '67%' }] },
94 { name: 'Nebula Run', status: 'planning', crew: 6, description: 'Misja do mglawicy Oriona',
95 stats: [{ label: 'Dystans', value: '1344 ly' }, { label: 'Czas', value: '2 lata' }, { label: 'Postep', value: '12%' }] },
96];
97
98export default function App() {
99 const [activeNav, setActiveNav] = useState('missions');
100 const isMobile = useMediaQuery('(max-width: 500px)');
101
102 return (
103 <div className="app">
104 <div className="layout">
105 {!isMobile && (
106 <Navigation items={navItems} active={activeNav} onSelect={setActiveNav} />
107 )}
108 <main className="main">
109 <h1 className="heading">Responsive Design w React</h1>
110 <p className="subtitle">
111 {isMobile ? 'Widok mobilny' : 'Widok desktop'} --
112 zmien rozmiar okna podgladu!
113 </p>
114 <div className="missions-grid">
115 {missions.map(m => <MissionCard key={m.name} {...m} />)}
116 </div>
117 </main>
118 </div>
119 {isMobile && (
120 <Navigation items={navItems} active={activeNav} onSelect={setActiveNav} />
121 )}
122 </div>
123 );
124}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Do czego służy custom hook useMediaQuery w React?
2. Co oznacza podejście mobile-first w responsive design?
To 2 z 4 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Układanie w poziomie
Ułóż składnię media query dla ekranów od 768px:
- Edytor kodu
Zaimplementuj nawigację responsywną z useMediaQuery
- Klikanie w kolejności
Ułóż składnię fluid typography z clamp() w CSS:
- Układanie w pionie
Ułóż typowe breakpointy mobile-first od najmniejszego do największego:
- Edytor kodu
Zaimplementuj kartę misji z różną zawartością na mobile i desktop