Kurs JavaScript i React · Moduł 14: Zaawansowane stylowanie

Responsive Design Patterns w React - Adaptacja do Każdej Przestrzeni

4 min czytania
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. 1. Do czego służy custom hook useMediaQuery w React?

  2. 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

Przydatne artykuły