import { useState, useRef, useEffect, useCallback } from "react";
export function useCarousel(length, { delay = 4000 } = {}) {
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
const savedCallback = useRef(null);
const next = useCallback(() => {
setIndex((i) => (i + 1) % length);
}, [length]);
const prev = useCallback(() => {
setIndex((i) => (i - 1 + length) % length);
}, [length]);
useEffect(() => {
savedCallback.current = () => {
if (!paused) next();
};
}, [paused, next]);
useEffect(() => {
if (delay == null) return undefined;
const id = setInterval(() => {
savedCallback.current();
}, delay);
return () => clearInterval(id);
}, [delay]);
return { index, next, prev, paused, setPaused, setIndex };
}
import React from "react";
import { useCarousel } from "./useCarousel";
import "./styles.css";
export default function Carousel({ images, delay = 4000 }) {
const { index, next, prev, setPaused, setIndex } = useCarousel(
images.length,
{ delay }
);
return (
<figure
className="carousel"
role="group"
aria-roledescription="carousel"
aria-label={`Image ${index + 1} of ${images.length}`}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<div
className="carousel-track"
style={{ transform: `translateX(-${index * 100}%)` }}
>
{images.map((src, i) => (
<img
key={src}
src={src}
alt=""
aria-hidden={i !== index}
className="carousel-slide"
/>
))}
</div>
<button className="nav prev" onClick={prev} aria-label="Previous slide">
‹
</button>
<button className="nav next" onClick={next} aria-label="Next slide">
›
</button>
<div className="dots" role="tablist">
{images.map((_, i) => (
<button
key={i}
role="tab"
aria-selected={i === index}
className={i === index ? "dot active" : "dot"}
onClick={() => setIndex(i)}
/>
))}
</div>
</figure>
);
}
.carousel {
position: relative;
width: 100%;
max-width: 640px;
margin: 0;
overflow: hidden;
border-radius: 12px;
}
.carousel-track {
display: flex;
transition: transform 500ms ease;
will-change: transform;
}
.carousel-slide {
flex: 0 0 100%;
width: 100%;
object-fit: cover;
aspect-ratio: 16 / 9;
user-select: none;
}
.nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
border: 0;
background: rgba(0, 0, 0, 0.45);
color: #fff;
font-size: 1.75rem;
line-height: 1;
padding: 0.25rem 0.6rem;
cursor: pointer;
}
.nav.prev { left: 8px; }
.nav.next { right: 8px; }
.dots {
position: absolute;
bottom: 10px;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 8px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 0;
background: rgba(255, 255, 255, 0.5);
cursor: pointer;
}
.dot.active { background: #fff; }
This snippet shows how an autoplaying image carousel is built in React so that the timer logic lives in a reusable custom hook rather than being tangled inside the component. The core problem is that a naive setInterval inside a component re-registers on every render and quietly captures a stale index value, so the carousel either advances erratically or never advances at all. The fix is to keep the interval callback in a mutable ref that is always up to date, while the interval itself is created exactly once.
In useCarousel hook, savedCallback is a useRef that is overwritten on every render with the latest advance closure. A separate useEffect with an empty dependency array installs a single setInterval that reads savedCallback.current at fire time, so the timer always calls the freshest logic without being torn down and rebuilt. This is the classic Dan Abramov "latest ref" pattern for declarative intervals. The paused flag short-circuits the tick, and delay being null lets a caller disable autoplay entirely by returning early before scheduling anything.
The hook exposes index, plus next and prev handlers wrapped in useCallback so they remain stable, and setPaused so the view can control playback. Index math uses modulo against length so wrapping is automatic in both directions, with + length guarding against a negative result in prev.
In Carousel component, onMouseEnter and onMouseLeave toggle setPaused, which is how pause-on-hover is achieved without any extra timers. The track is translated with transform: translateX driven by index, which is cheaper for the browser to animate than shifting layout. The figure uses aria-roledescription and a live label so assistive tech understands it is a rotating region.
A subtle trade-off: because the interval is never recreated, changing delay at runtime requires the effect to depend on delay, which this version does. Pausing on focus/blur as well as hover would make it more accessible, and honoring prefers-reduced-motion is a sensible extension. The styles.css tab keeps the transition and overflow: hidden that make the sliding effect read as a carousel. This pattern generalizes to any polling or ticking UI where the callback changes but the schedule should not.
Related snips
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Semantic HTML Example</title>
Semantic HTML5 elements and accessibility best practices
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
import { Application } from "@hotwired/stimulus"
import FormSubmitController from "./controllers/form_submit_controller"
const application = Application.start()
application.debug = false
Disable submit button while Turbo form is submitting
Share this code
Here's the card — post it anywhere.