Take the carousel and make the slides glide. Same single index, same wrap — but instead of swapping the visible slide, lay all slides in a row inside a clipped viewport and shift that row with transform: translateX(-index * 100%). A CSS transition on the transform animates the move.
// A self-contained component. No props.
function App(): JSX.Element;
A clipped viewport with a sliding track of all slides.
index 1 -> track shifts to translateX(-100%), animating from 0%
index 2 -> translateX(-200%); the transition eases between them
viewport: overflow hidden; track: flex row, each slide flex 0 0 100%
transform: translateX(-index * 100%) on the track; the index math is unchanged.transition: transform … makes the shift glide — no JS animation loop.Same state as the base carousel — one index — but a different layout. Put every slide side by side in a flex track, clip the viewport to one slide's width, and move the track with translateX(-index * 100%). A CSS transition on that transform does the animation for you.
In the base carousel we swapped which single slide was in the DOM — instant, no motion. To animate, the slides need to physically move. So lay them all out in one long horizontal strip (the track) inside a window that shows just one. Sliding the strip left by one slide-width reveals the next; doing that with a transformed translateX and a CSS transition makes the browser tween it smoothly. The index and wrap logic don't change at all — only how the DOM is arranged and shifted.
The viewport (.carousel) has overflow: hidden. Inside, a .track is a flex row where each slide is flex: 0 0 100% (exactly the viewport width). The track's transform is translateX(-index * 100%) — index 0 → 0%, index 1 → -100%, etc. With transition: transform 0.4s on the track, changing index animates the slide. Prev/Next/dots set index exactly as before.
A first attempt animates each slide's own position or toggles opacity:
// fade: render one slide, animate opacity 0→1 on change
// …but it's a crossfade, not a slide, and you still only have one slide to move
Opacity fades are fine, but for a slide you need adjacent slides physically next to each other to move between — which means laying them in a track. Translating one shared track is also cheaper than animating N individual elements, and the browser compositor handles transform transitions smoothly.
import { useState } from 'react';
import './styles.css';
const SLIDES = [
{ label: 'Sunset', bg: 'linear-gradient(135deg, #fb7185, #fbbf24)' },
{ label: 'Ocean', bg: 'linear-gradient(135deg, #38bdf8, #6366f1)' },
{ label: 'Forest', bg: 'linear-gradient(135deg, #4ade80, #16a34a)' },
{ label: 'Berry', bg: 'linear-gradient(135deg, #a78bfa, #ec4899)' },
];
export default function App() {
const [index, setIndex] = useState(0);
const n = SLIDES.length;
const prev = () => setIndex((i) => (i - 1 + n) % n);
const next = () => setIndex((i) => (i + 1) % n);
return (
<main className="container">
<h1>Image Carousel II</h1>
<div className="carousel">
<button className="nav prev" aria-label="Previous" onClick={prev}>
‹
</button>
<div className="track" style={{ transform: `translateX(-${index * 100}%)` }}>
{SLIDES.map((s) => (
<div key={s.label} className="slide" style={{ background: s.bg }}>
{s.label}
</div>
))}
</div>
<button className="nav next" aria-label="Next" onClick={next}>
›
</button>
</div>
<div className="dots">
{SLIDES.map((_, i) => (
<button
key={i}
className={i === index ? 'dot active' : 'dot'}
aria-label={`Go to slide ${i + 1}`}
onClick={() => setIndex(i)}
/>
))}
</div>
</main>
);
}
The component logic is the base carousel's — one index, modulo wrap. What changed is the render: instead of SLIDES[index], all slides are mapped into the .track, and the track's inline transform: translateX(-${index * 100}%) shifts it. The CSS (.track { transition: transform 0.4s ease }, .slide { flex: 0 0 100% }, .carousel { overflow: hidden }) makes the move glide and clips the overflow. The dots still derive from index.
index = 0. Track at translateX(0%); Sunset fills the viewport, the other three sit off to the right.
index = 1 → track style becomes translateX(-100%). The CSS transition eases the track left over 0.4s; Ocean slides in, Sunset slides out.index = 2 → translateX(-200%) → glides to Forest.index = 0 → translateX(0%) → the track glides all the way back; the transition animates the longer move too.(3+1)%4 = 0 → translateX(0%); it animates backward across all slides (a known trade-off of the simple track; infinite-loop carousels clone edge slides — out of scope).overflow: hidden. All slides show at once. Fix: clip the viewport.flex: 0 0 100% they shrink to fit and the math breaks. Fix: lock each to 100%.transition: transform on the track, not the slides.This version gives every navigation intent a named reducer action. The reducer owns wrapping, while the render still derives the transform and active dot from one index.
import { useReducer } from 'react';
import './styles.css';
const SLIDES = [
{ label: 'Sunset', bg: 'linear-gradient(135deg, #fb7185, #fbbf24)' },
{ label: 'Ocean', bg: 'linear-gradient(135deg, #38bdf8, #6366f1)' },
{ label: 'Forest', bg: 'linear-gradient(135deg, #4ade80, #16a34a)' },
{ label: 'Berry', bg: 'linear-gradient(135deg, #a78bfa, #ec4899)' },
];
type Action =
| { type: 'previous' }
| { type: 'next' }
| { type: 'select'; index: number };
function reducer(index: number, action: Action) {
if (action.type === 'select') return action.index;
const offset = action.type === 'next' ? 1 : -1;
return (index + offset + SLIDES.length) % SLIDES.length;
}
export default function App() {
const [index, dispatch] = useReducer(reducer, 0);
return (
<main className="container">
<h1>Image Carousel II</h1>
<div className="carousel">
<button className="nav prev" aria-label="Previous" onClick={() => dispatch({ type: 'previous' })}>‹</button>
<div className="track" style={{ transform: `translateX(-${index * 100}%)` }}>
{SLIDES.map((slide) => (
<div key={slide.label} className="slide" style={{ background: slide.bg }}>
{slide.label}
</div>
))}
</div>
<button className="nav next" aria-label="Next" onClick={() => dispatch({ type: 'next' })}>›</button>
</div>
<div className="dots">
{SLIDES.map((slide, dotIndex) => (
<button
key={slide.label}
className={dotIndex === index ? 'dot active' : 'dot'}
aria-label={`Go to slide ${dotIndex + 1}`}
onClick={() => dispatch({ type: 'select', index: dotIndex })}
/>
))}
</div>
</main>
);
}This version stores a stable slide id instead of a numeric position. It derives the current index for transforms and wrapped movement, which keeps state meaningful if slide records later carry more data.
import { useState } from 'react';
import './styles.css';
const SLIDES = [
{ id: 'sunset', label: 'Sunset', bg: 'linear-gradient(135deg, #fb7185, #fbbf24)' },
{ id: 'ocean', label: 'Ocean', bg: 'linear-gradient(135deg, #38bdf8, #6366f1)' },
{ id: 'forest', label: 'Forest', bg: 'linear-gradient(135deg, #4ade80, #16a34a)' },
{ id: 'berry', label: 'Berry', bg: 'linear-gradient(135deg, #a78bfa, #ec4899)' },
];
export default function App() {
const [selectedId, setSelectedId] = useState(SLIDES[0].id);
const index = SLIDES.findIndex((slide) => slide.id === selectedId);
function move(offset: number) {
const nextIndex = (index + offset + SLIDES.length) % SLIDES.length;
setSelectedId(SLIDES[nextIndex].id);
}
return (
<main className="container">
<h1>Image Carousel II</h1>
<div className="carousel">
<button className="nav prev" aria-label="Previous" onClick={() => move(-1)}>‹</button>
<div className="track" style={{ transform: `translateX(-${index * 100}%)` }}>
{SLIDES.map((slide) => (
<div key={slide.id} className="slide" style={{ background: slide.bg }}>
{slide.label}
</div>
))}
</div>
<button className="nav next" aria-label="Next" onClick={() => move(1)}>›</button>
</div>
<div className="dots">
{SLIDES.map((slide, dotIndex) => (
<button
key={slide.id}
className={slide.id === selectedId ? 'dot active' : 'dot'}
aria-label={`Go to slide ${dotIndex + 1}`}
onClick={() => setSelectedId(slide.id)}
/>
))}
</div>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Take the carousel and make the slides glide. Same single index, same wrap — but instead of swapping the visible slide, lay all slides in a row inside a clipped viewport and shift that row with transform: translateX(-index * 100%). A CSS transition on the transform animates the move.
// A self-contained component. No props.
function App(): JSX.Element;
A clipped viewport with a sliding track of all slides.
index 1 -> track shifts to translateX(-100%), animating from 0%
index 2 -> translateX(-200%); the transition eases between them
viewport: overflow hidden; track: flex row, each slide flex 0 0 100%
transform: translateX(-index * 100%) on the track; the index math is unchanged.transition: transform … makes the shift glide — no JS animation loop.Same state as the base carousel — one index — but a different layout. Put every slide side by side in a flex track, clip the viewport to one slide's width, and move the track with translateX(-index * 100%). A CSS transition on that transform does the animation for you.
In the base carousel we swapped which single slide was in the DOM — instant, no motion. To animate, the slides need to physically move. So lay them all out in one long horizontal strip (the track) inside a window that shows just one. Sliding the strip left by one slide-width reveals the next; doing that with a transformed translateX and a CSS transition makes the browser tween it smoothly. The index and wrap logic don't change at all — only how the DOM is arranged and shifted.
The viewport (.carousel) has overflow: hidden. Inside, a .track is a flex row where each slide is flex: 0 0 100% (exactly the viewport width). The track's transform is translateX(-index * 100%) — index 0 → 0%, index 1 → -100%, etc. With transition: transform 0.4s on the track, changing index animates the slide. Prev/Next/dots set index exactly as before.
A first attempt animates each slide's own position or toggles opacity:
// fade: render one slide, animate opacity 0→1 on change
// …but it's a crossfade, not a slide, and you still only have one slide to move
Opacity fades are fine, but for a slide you need adjacent slides physically next to each other to move between — which means laying them in a track. Translating one shared track is also cheaper than animating N individual elements, and the browser compositor handles transform transitions smoothly.
import { useState } from 'react';
import './styles.css';
const SLIDES = [
{ label: 'Sunset', bg: 'linear-gradient(135deg, #fb7185, #fbbf24)' },
{ label: 'Ocean', bg: 'linear-gradient(135deg, #38bdf8, #6366f1)' },
{ label: 'Forest', bg: 'linear-gradient(135deg, #4ade80, #16a34a)' },
{ label: 'Berry', bg: 'linear-gradient(135deg, #a78bfa, #ec4899)' },
];
export default function App() {
const [index, setIndex] = useState(0);
const n = SLIDES.length;
const prev = () => setIndex((i) => (i - 1 + n) % n);
const next = () => setIndex((i) => (i + 1) % n);
return (
<main className="container">
<h1>Image Carousel II</h1>
<div className="carousel">
<button className="nav prev" aria-label="Previous" onClick={prev}>
‹
</button>
<div className="track" style={{ transform: `translateX(-${index * 100}%)` }}>
{SLIDES.map((s) => (
<div key={s.label} className="slide" style={{ background: s.bg }}>
{s.label}
</div>
))}
</div>
<button className="nav next" aria-label="Next" onClick={next}>
›
</button>
</div>
<div className="dots">
{SLIDES.map((_, i) => (
<button
key={i}
className={i === index ? 'dot active' : 'dot'}
aria-label={`Go to slide ${i + 1}`}
onClick={() => setIndex(i)}
/>
))}
</div>
</main>
);
}
The component logic is the base carousel's — one index, modulo wrap. What changed is the render: instead of SLIDES[index], all slides are mapped into the .track, and the track's inline transform: translateX(-${index * 100}%) shifts it. The CSS (.track { transition: transform 0.4s ease }, .slide { flex: 0 0 100% }, .carousel { overflow: hidden }) makes the move glide and clips the overflow. The dots still derive from index.
index = 0. Track at translateX(0%); Sunset fills the viewport, the other three sit off to the right.
index = 1 → track style becomes translateX(-100%). The CSS transition eases the track left over 0.4s; Ocean slides in, Sunset slides out.index = 2 → translateX(-200%) → glides to Forest.index = 0 → translateX(0%) → the track glides all the way back; the transition animates the longer move too.(3+1)%4 = 0 → translateX(0%); it animates backward across all slides (a known trade-off of the simple track; infinite-loop carousels clone edge slides — out of scope).overflow: hidden. All slides show at once. Fix: clip the viewport.flex: 0 0 100% they shrink to fit and the math breaks. Fix: lock each to 100%.transition: transform on the track, not the slides.This version gives every navigation intent a named reducer action. The reducer owns wrapping, while the render still derives the transform and active dot from one index.
import { useReducer } from 'react';
import './styles.css';
const SLIDES = [
{ label: 'Sunset', bg: 'linear-gradient(135deg, #fb7185, #fbbf24)' },
{ label: 'Ocean', bg: 'linear-gradient(135deg, #38bdf8, #6366f1)' },
{ label: 'Forest', bg: 'linear-gradient(135deg, #4ade80, #16a34a)' },
{ label: 'Berry', bg: 'linear-gradient(135deg, #a78bfa, #ec4899)' },
];
type Action =
| { type: 'previous' }
| { type: 'next' }
| { type: 'select'; index: number };
function reducer(index: number, action: Action) {
if (action.type === 'select') return action.index;
const offset = action.type === 'next' ? 1 : -1;
return (index + offset + SLIDES.length) % SLIDES.length;
}
export default function App() {
const [index, dispatch] = useReducer(reducer, 0);
return (
<main className="container">
<h1>Image Carousel II</h1>
<div className="carousel">
<button className="nav prev" aria-label="Previous" onClick={() => dispatch({ type: 'previous' })}>‹</button>
<div className="track" style={{ transform: `translateX(-${index * 100}%)` }}>
{SLIDES.map((slide) => (
<div key={slide.label} className="slide" style={{ background: slide.bg }}>
{slide.label}
</div>
))}
</div>
<button className="nav next" aria-label="Next" onClick={() => dispatch({ type: 'next' })}>›</button>
</div>
<div className="dots">
{SLIDES.map((slide, dotIndex) => (
<button
key={slide.label}
className={dotIndex === index ? 'dot active' : 'dot'}
aria-label={`Go to slide ${dotIndex + 1}`}
onClick={() => dispatch({ type: 'select', index: dotIndex })}
/>
))}
</div>
</main>
);
}This version stores a stable slide id instead of a numeric position. It derives the current index for transforms and wrapped movement, which keeps state meaningful if slide records later carry more data.
import { useState } from 'react';
import './styles.css';
const SLIDES = [
{ id: 'sunset', label: 'Sunset', bg: 'linear-gradient(135deg, #fb7185, #fbbf24)' },
{ id: 'ocean', label: 'Ocean', bg: 'linear-gradient(135deg, #38bdf8, #6366f1)' },
{ id: 'forest', label: 'Forest', bg: 'linear-gradient(135deg, #4ade80, #16a34a)' },
{ id: 'berry', label: 'Berry', bg: 'linear-gradient(135deg, #a78bfa, #ec4899)' },
];
export default function App() {
const [selectedId, setSelectedId] = useState(SLIDES[0].id);
const index = SLIDES.findIndex((slide) => slide.id === selectedId);
function move(offset: number) {
const nextIndex = (index + offset + SLIDES.length) % SLIDES.length;
setSelectedId(SLIDES[nextIndex].id);
}
return (
<main className="container">
<h1>Image Carousel II</h1>
<div className="carousel">
<button className="nav prev" aria-label="Previous" onClick={() => move(-1)}>‹</button>
<div className="track" style={{ transform: `translateX(-${index * 100}%)` }}>
{SLIDES.map((slide) => (
<div key={slide.id} className="slide" style={{ background: slide.bg }}>
{slide.label}
</div>
))}
</div>
<button className="nav next" aria-label="Next" onClick={() => move(1)}>›</button>
</div>
<div className="dots">
{SLIDES.map((slide, dotIndex) => (
<button
key={slide.id}
className={slide.id === selectedId ? 'dot active' : 'dot'}
aria-label={`Go to slide ${dotIndex + 1}`}
onClick={() => setSelectedId(slide.id)}
/>
))}
</div>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.