Kurs JavaScript i React · Moduł 7: Stylowanie i animacje

Animacje CSS i keyframes w React

6 min czytania
W tej lekcji6

Statki kosmiczne nie pojawiają się nagle na ekranie radaru - wlatują, pulsują sygnałem, płynnie przesuwają się po orbicie. Animacje w interfejsach webowych działają na tej samej zasadzie: nadają aplikacji dynamiki i pomagają użytkownikowi zrozumieć, co się dzieje. W tej lekcji poznasz techniki tworzenia animacji CSS w aplikacjach React - od prostych przejść po złożone sekwencje z keyframes, a na końcu zajrzymy do biblioteki React Transition Group.

CSS @keyframes z styled-components

Reguła @keyframes pozwala zdefiniować sekwencję kroków animacji. W styled-components korzystamy z helpera keyframes, który generuje unikalną nazwę animacji i zapobiega konfliktom:

1import styled, { keyframes } from 'styled-components';
2
3// Definicja animacji - pulsowanie sygnału radarowego
4const radarPulse = keyframes`
5  0% {
6    transform: scale(1);
7    opacity: 1;
8  }
9  50% {
10    transform: scale(1.5);
11    opacity: 0.5;
12  }
13  100% {
14    transform: scale(2);
15    opacity: 0;
16  }
17`;
18
19const RadarDot = styled.div`
20  width: 20px;
21  height: 20px;
22  border-radius: 50%;
23  background: #00ff88;
24  animation: ${radarPulse} 2s ease-out infinite;
25`;

Kluczowe elementy:

  • keyframes importujemy z styled-components (nie z CSS)
  • Wewnątrz definiujemy kroki procentowe (0%, 50%, 100%) lub słowa kluczowe (from, to)
  • Używamy interpolacji ${radarPulse} w propercie animation

Możesz definiować dowolnie wiele keyframes i łączyć je w różnych komponentach:

1const floatInSpace = keyframes`
2  0%, 100% { transform: translateY(0px); }
3  50% { transform: translateY(-15px); }
4`;
5
6const rotateOrbit = keyframes`
7  from { transform: rotate(0deg); }
8  to { transform: rotate(360deg); }
9`;
10
11const SpaceStation = styled.div`
12  animation: ${floatInSpace} 4s ease-in-out infinite;
13`;
14
15const OrbitRing = styled.div`
16  animation: ${rotateOrbit} 10s linear infinite;
17`;

Transition - płynne przejścia stanów

Właściwość transition w CSS to najprostszy sposób na animowanie zmian wartości. Zamiast definiować keyframes, mówisz przeglądarce: "gdy ta właściwość się zmieni, zrób to płynnie". To idealne rozwiązanie dla efektów hover, kliknięcia czy zmiany stanu komponentu.

1const NavButton = styled.button`
2  background: #1a1a3e;
3  color: #00ff88;
4  padding: 12px 24px;
5  border: 2px solid #00ff88;
6  border-radius: 8px;
7  cursor: pointer;
8
9  /* Definiujemy, co ma być animowane i jak */
10  transition: all 0.3s ease;
11
12  &:hover {
13    background: #00ff88;
14    color: #1a1a3e;
15    transform: scale(1.05);
16    box-shadow: 0 0 20px rgba(0, 255, 136, 0.4);
17  }
18`;

Składnia transition:

1transition: property duration timing-function delay;
  • property - co animujemy (all, background, transform, opacity...)
  • duration - jak długo trwa przejście (0.3s, 200ms)
  • timing-function - krzywa animacji (ease, linear, ease-in-out, cubic-bezier())
  • delay - opóźnienie startu (opcjonalne)

Możesz animować wiele właściwości z różnymi parametrami:

1const ShipPanel = styled.div`
2  opacity: 0.8;
3  transform: translateX(0);
4  background: #0a0a2e;
5
6  transition:
7    opacity 0.2s ease,
8    transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
9    background 0.3s ease;
10
11  &:hover {
12    opacity: 1;
13    transform: translateX(10px);
14    background: #1a1a4e;
15  }
16`;

Animation - ciągłe animacje

Właściwość animation to bardziej zaawansowana wersja transition. Pozwala na ciągłe, powtarzające się animacje, odtwarzanie wstecz i kontrolę nad każdym aspektem ruchu:

1const warningBlink = keyframes`
2  0%, 100% { opacity: 1; }
3  50% { opacity: 0.3; }
4`;
5
6const WarningLight = styled.div`
7  width: 16px;
8  height: 16px;
9  border-radius: 50%;
10  background: #ff4444;
11
12  animation-name: ${warningBlink};
13  animation-duration: 1s;
14  animation-timing-function: ease-in-out;
15  animation-iteration-count: infinite;
16  animation-direction: alternate;
17`;

Albo w skróconej formie:

1animation: name duration timing-function delay iteration-count direction fill-mode;

Najważniejsze parametry:

  • iteration-count - infinite (nieskończone) lub liczba powtórzeń (3)
  • direction - normal, reverse, alternate (tam i z powrotem)
  • fill-mode - forwards (zachowaj stan końcowy), backwards, both
  • play-state - running lub paused (kontrola odtwarzania)

Dynamiczne animacje oparte na props

W styled-components możesz tworzyć animacje, które reagują na props komponentu. To pozwala na dynamiczne dostosowanie ruchu do stanu aplikacji:

1const moveShip = keyframes`
2  from { transform: translateX(0); }
3  to { transform: translateX(100%); }
4`;
5
6const Spaceship = styled.div`
7  width: 60px;
8  height: 30px;
9  background: #4488ff;
10  clip-path: polygon(0% 50%, 30% 0%, 100% 50%, 30% 100%);
11
12  animation: ${moveShip} ${props => props.speed || 3}s linear infinite;
13  animation-play-state: ${props => props.isFlying ? 'running' : 'paused'};
14  opacity: ${props => props.isFlying ? 1 : 0.5};
15  transition: opacity 0.3s ease;
16`;
17
18function FlightControl() {
19  const [isFlying, setIsFlying] = useState(false);
20  const [speed, setSpeed] = useState(3);
21
22  return (
23    <div>
24      <Spaceship isFlying={isFlying} speed={speed} />
25
26      <button onClick={() => setIsFlying(!isFlying)}>
27        {isFlying ? 'Zatrzymaj' : 'Leć'}
28      </button>
29
30      <input
31        type="range"
32        min="0.5"
33        max="5"
34        step="0.5"
35        value={speed}
36        onChange={(e) => setSpeed(e.target.value)}
37      />
38      <span>Prędkość: {speed}s na okrążenie</span>
39    </div>
40  );
41}

Możesz też dynamicznie wybierać różne keyframes w zależności od stanu:

1const slideIn = keyframes`
2  from { transform: translateX(-100%); opacity: 0; }
3  to { transform: translateX(0); opacity: 1; }
4`;
5
6const slideOut = keyframes`
7  from { transform: translateX(0); opacity: 1; }
8  to { transform: translateX(100%); opacity: 0; }
9`;
10
11const Panel = styled.div`
12  animation: ${props => props.isVisible ? slideIn : slideOut} 0.4s ease forwards;
13`;

To podejście pozwala na animowanie wejścia i wyjścia elementów - panel wsuwa się z lewej strony przy pojawieniu i wysuwa w prawo przy znikaniu.

React Transition Group - CSSTransition

Natywne CSS nie potrafi animować elementów, które są dodawane lub usuwane z DOM (np. przez warunkowe renderowanie {show && <Component />}). Biblioteka React Transition Group rozwiązuje ten problem, zarządzając cyklem życia animacji wejścia/wyjścia.

Najczęściej używany komponent to CSSTransition:

1import { CSSTransition } from 'react-transition-group';
2import { useState, useRef } from 'react';
3
4function NotificationPanel() {
5  const [showAlert, setShowAlert] = useState(false);
6  const nodeRef = useRef(null);
7
8  return (
9    <div>
10      <button onClick={() => setShowAlert(!showAlert)}>
11        {showAlert ? 'Ukryj alert' : 'Pokaż alert'}
12      </button>
13
14      <CSSTransition
15        in={showAlert}
16        timeout={300}
17        classNames="alert"
18        unmountOnExit
19        nodeRef={nodeRef}
20      >
21        <div ref={nodeRef} className="alert-box">
22          Wykryto obiekt na radarze!
23        </div>
24      </CSSTransition>
25    </div>
26  );
27}

CSSTransition automatycznie dodaje i usuwa klasy CSS w odpowiednich momentach:

1/* Wejście - start */
2.alert-enter {
3  opacity: 0;
4  transform: translateY(-20px);
5}
6
7/* Wejście - aktywna faza */
8.alert-enter-active {
9  opacity: 1;
10  transform: translateY(0);
11  transition: opacity 300ms ease, transform 300ms ease;
12}
13
14/* Wyjście - start */
15.alert-exit {
16  opacity: 1;
17  transform: translateY(0);
18}
19
20/* Wyjście - aktywna faza */
21.alert-exit-active {
22  opacity: 0;
23  transform: translateY(-20px);
24  transition: opacity 300ms ease, transform 300ms ease;
25}

Kluczowe propsy CSSTransition:

  • in - boolean kontrolujący, czy element jest widoczny
  • timeout - czas trwania animacji (w ms), powinien odpowiadać CSS transition
  • classNames - prefiks klas CSS (np. "alert" generuje alert-enter, alert-enter-active itd.)
  • unmountOnExit - usuwa element z DOM po animacji wyjścia
  • nodeRef - ref do animowanego elementu (zalecane dla uniknięcia ostrzeżeń)

Transition vs Animation - kiedy co wybrać?

Podsumujmy, kiedy używać poszczególnych technik:

transition - dla prostych zmian stanów:

  • Efekty hover, focus, active
  • Zmiana koloru, rozmiaru, pozycji przy interakcji
  • Proste wejścia/wyjścia (opacity, transform)

@keyframes + animation - dla złożonych i ciągłych animacji:

  • Pulsowanie, obracanie, latanie
  • Animacje wieloetapowe (więcej niż 2 stany)
  • Nieskończone animacje tła lub dekoracji

React Transition Group - dla animacji cyklu życia komponentów:

  • Animowane montowanie/odmontowanie elementów
  • Listy z animowanym dodawaniem/usuwaniem elementów
  • Przejścia między widokami/stronami

Dobrze zaprojektowane animacje to jak system nawigacyjny statku - prowadzą użytkownika, dają feedback i sprawiają, że interfejs czuje się naturalnie, jak lot przez galaktykę z warp drive, a nie teleportacja bez ostrzeżenia.

Kod do tej lekcji: App.jsx
1import React, { useState } from 'react';
2import styled, { keyframes, css, createGlobalStyle } from 'styled-components';
3
4// ===== GLOBAL STYLES =====
5const GlobalStyle = createGlobalStyle`
6  * { margin: 0; padding: 0; box-sizing: border-box; }
7  body {
8    font-family: 'Segoe UI', sans-serif;
9    background: #0a0a1a;
10    color: #e0e0e0;
11  }
12`;
13
14// ===== ANIMACJE KEYFRAMES =====
15
16// 1. Rotacja - idealna dla spinnerów
17const spin = keyframes`
18  from { transform: rotate(0deg); }
19  to { transform: rotate(360deg); }
20`;
21
22// 2. Pulsowanie - efekt bicia serca
23const pulse = keyframes`
24  0%, 100% { transform: scale(1); opacity: 1; }
25  50% { transform: scale(1.05); opacity: 0.8; }
26`;
27
28// 3. Efekt unoszenia się (float)
29const float = keyframes`
30  0%, 100% { transform: translateY(0); }
31  50% { transform: translateY(-12px); }
32`;
33
34// 4. Efekt świecenia (glow)
35const glow = keyframes`
36  0%, 100% { box-shadow: 0 0 5px rgba(0, 212, 255, 0.3); }
37  50% { box-shadow: 0 0 25px rgba(0, 212, 255, 0.6), 0 0 50px rgba(0, 212, 255, 0.3); }
38`;
39
40// 5. Wejście z lewej strony
41const slideInLeft = keyframes`
42  from {
43    opacity: 0;
44    transform: translateX(-60px);
45  }
46  to {
47    opacity: 1;
48    transform: translateX(0);
49  }
50`;
51
52// TODO: Stwórz animację fadeInUp
53// Element powinien pojawiać się od dołu z opacity 0
54// from: opacity 0, translateY(30px)
55// to: opacity 1, translateY(0)
56const fadeInUp = keyframes`
57  from {
58    opacity: 0;
59    transform: translateY(30px);
60  }
61  to {
62    opacity: 1;
63    transform: translateY(0);
64  }
65`;
66
67// ===== LAYOUT =====
68const Container = styled.div`
69  min-height: 100vh;
70  padding: 30px;
71  max-width: 700px;
72  margin: 0 auto;
73`;
74
75const Title = styled.h1`
76  text-align: center;
77  color: #00d4ff;
78  font-size: 2em;
79  margin-bottom: 5px;
80  animation: ${float} 3s ease-in-out infinite;
81`;
82
83const Subtitle = styled.p`
84  text-align: center;
85  color: #778da9;
86  margin-bottom: 35px;
87`;
88
89const Section = styled.div`
90  margin-bottom: 30px;
91  animation: ${slideInLeft} 0.6s ease-out;
92  animation-delay: ${p => p.delay || '0s'};
93  animation-fill-mode: both;
94`;
95
96const SectionTitle = styled.h3`
97  color: #6c5ce7;
98  margin-bottom: 15px;
99`;
100
101// ===== LOADING SPINNER =====
102const SpinnerWrapper = styled.div`
103  display: flex;
104  align-items: center;
105  gap: 15px;
106`;
107
108const Spinner = styled.div`
109  width: 40px;
110  height: 40px;
111  border: 3px solid rgba(0, 212, 255, 0.2);
112  border-top-color: #00d4ff;
113  border-radius: 50%;
114  animation: ${spin} 0.8s linear infinite;
115`;
116
117// ===== PULSUJĄCY PRZYCISK =====
118const PulseButton = styled.button`
119  padding: 12px 28px;
120  background: #6c5ce7;
121  color: white;
122  border: none;
123  border-radius: 10px;
124  font-size: 1rem;
125  font-weight: 600;
126  cursor: pointer;
127  animation: ${pulse} 2s ease-in-out infinite;
128  transition: background 0.3s;
129
130  &:hover {
131    background: #5a4bd1;
132    animation-play-state: paused;
133  }
134`;
135
136// ===== ŚWIECĄCA KARTA =====
137const GlowCard = styled.div`
138  background: rgba(255, 255, 255, 0.04);
139  border: 1px solid rgba(0, 212, 255, 0.2);
140  border-radius: 12px;
141  padding: 24px;
142  animation: ${glow} 3s ease-in-out infinite;
143  transition: transform 0.3s;
144
145  &:hover {
146    transform: scale(1.02);
147  }
148
149  h4 {
150    color: #00d4ff;
151    margin-bottom: 8px;
152  }
153`;
154
155// ===== ANIMOWANE KARTY Z OPÓŹNIENIEM =====
156const StaggerCard = styled.div`
157  background: rgba(108, 92, 231, 0.1);
158  border-left: 4px solid #6c5ce7;
159  border-radius: 8px;
160  padding: 16px;
161  margin-bottom: 10px;
162  animation: ${fadeInUp} 0.5s ease-out both;
163  animation-delay: ${p => p.delay || '0s'};
164`;
165
166// TODO: Stwórz komponent AnimatedBadge z animacją warunkową
167// Gdy prop active=true -> animacja pulse
168// Gdy active=false -> brak animacji
169const AnimatedBadge = styled.span`
170  display: inline-block;
171  padding: 4px 14px;
172  border-radius: 20px;
173  font-size: 0.8rem;
174  font-weight: 700;
175  background: ${p => p.active ? 'rgba(0, 184, 148, 0.2)' : 'rgba(99, 110, 114, 0.2)'};
176  color: ${p => p.active ? '#00b894' : '#636e72'};
177  animation: ${p => p.active ? css`${pulse} 2s infinite` : 'none'};
178`;
179
180// TODO: Stwórz OrbitDot - kropkę krążącą po okręgu
181// Użyj @keyframes z transform: rotate() i translateX()
182const orbit = keyframes`
183  from { transform: rotate(0deg) translateX(30px) rotate(0deg); }
184  to { transform: rotate(360deg) translateX(30px) rotate(-360deg); }
185`;
186
187const OrbitContainer = styled.div`
188  position: relative;
189  width: 80px;
190  height: 80px;
191  display: flex;
192  align-items: center;
193  justify-content: center;
194`;
195
196const OrbitCenter = styled.div`
197  width: 16px;
198  height: 16px;
199  background: #fdcb6e;
200  border-radius: 50%;
201  box-shadow: 0 0 15px rgba(253, 203, 110, 0.5);
202`;
203
204const OrbitDot = styled.div`
205  position: absolute;
206  width: 8px;
207  height: 8px;
208  background: #00d4ff;
209  border-radius: 50%;
210  animation: ${orbit} ${p => p.duration || '3s'} linear infinite;
211`;
212
213// ===== APLIKACJA =====
214function App() {
215  const [showCards, setShowCards] = useState(false);
216
217  const missions = [
218    { name: 'Missja Alpha', status: true },
219    { name: 'Missja Beta', status: true },
220    { name: 'Missja Gamma', status: false },
221    { name: 'Missja Delta', status: true },
222  ];
223
224  return (
225    <>
226      <GlobalStyle />
227      <Container>
228        <Title>CSS Animations & Keyframes</Title>
229        <Subtitle>Animacje w styled-components</Subtitle>
230
231        {/* Sekcja: Spinner */}
232        <Section delay="0s">
233          <SectionTitle>Loading Spinner</SectionTitle>
234          <SpinnerWrapper>
235            <Spinner />
236            <span>Skanowanie sektora kosmicznego...</span>
237          </SpinnerWrapper>
238        </Section>
239
240        {/* Sekcja: Pulsujący przycisk */}
241        <Section delay="0.15s">
242          <SectionTitle>Pulsujący przycisk</SectionTitle>
243          <PulseButton onClick={() => setShowCards(!showCards)}>
244            {showCards ? 'Ukryj misje' : 'Pokaż misje'}
245          </PulseButton>
246        </Section>
247
248        {/* Sekcja: Animowane karty z opóźnieniem */}
249        {showCards && (
250          <Section delay="0s">
251            <SectionTitle>Staggered Cards</SectionTitle>
252            {missions.map((m, i) => (
253              <StaggerCard key={i} delay={i * 0.12 + 's'}>
254                <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
255                  <span>{m.name}</span>
256                  <AnimatedBadge active={m.status}>
257                    {m.status ? 'ACTIVE' : 'OFFLINE'}
258                  </AnimatedBadge>
259                </div>
260              </StaggerCard>
261            ))}
262          </Section>
263        )}
264
265        {/* Sekcja: Świecąca karta */}
266        <Section delay="0.3s">
267          <SectionTitle>Glow Effect</SectionTitle>
268          <GlowCard>
269            <h4>Stacja kosmiczna Nebula-7</h4>
270            <p>Karta z efektem pulsującego świecenia CSS box-shadow.</p>
271          </GlowCard>
272        </Section>
273
274        {/* Sekcja: Orbita */}
275        <Section delay="0.45s">
276          <SectionTitle>Orbit Animation</SectionTitle>
277          <div style={{ display: 'flex', alignItems: 'center', gap: 30 }}>
278            <OrbitContainer>
279              <OrbitCenter />
280              <OrbitDot duration="2.5s" />
281              <OrbitDot duration="4s" />
282            </OrbitContainer>
283            <span>Orbita z użyciem rotate + translateX</span>
284          </div>
285        </Section>
286      </Container>
287    </>
288  );
289}
290
291export default App;

Widzisz błąd w tej lekcji?

Zadania praktyczne w grze

  • Klikanie w kolejności

    Ułóż definicję CSS Custom Property:

Przydatne artykuły