Thumb Progress Carousel
A carousel component featuring auto-play functionality and a thumbnail navigation strip that visualizes the remaining time for the current slide.
Hero Section Integration
A standard implementation suitable for landing page hero sections. This demo showcases the default 5-second interval, perfect for keeping content moving without overwhelming the user. The layout integrates a text header that stacks gracefully on smaller screens.
High-Energy Retail
Demonstrates how adjusting the interval prop to 3000ms creates a faster-paced, higher-energy vibe ideal for flash sales, fashion drops, or promotional content where you want to cycle through products quickly to capture attention.
Cinematic Gallery
A sophisticated layout for art or architecture portfolios. By increasing the interval to 8000ms, the carousel encourages users to linger on details. This demo also illustrates how the component fits into a side-by-side layout using responsive grid techniques.
Installation
CLI (Recommended)
npx shadcn-ui@latest add thumb-progress-carouselManual
This component relies on the shadcn/ui Carousel component.
npx shadcn@latest add https://satisui.xyz/r/thumb-progres-carousel.jsonCopy the following code into components/thumb-progress-carousel.tsx.
'use client';
import * as React from 'react';
import Image from 'next/image';
import {
Carousel,
CarouselApi,
CarouselContent,
CarouselItem,
} from '@/components/ui/carousel';
import { cn } from '@/lib/utils';
interface CarouselItemData {
id: string;
image: string;
title: string;
}
interface ThumbProgressCarouselProps {
/**
* The array of slides to display.
*/
items: CarouselItemData[];
/**
* Auto-play interval duration in milliseconds.
* @default 5000
*/
interval?: number;
}
/**
* A carousel component featuring auto-play functionality and a thumbnail navigation
* strip that visualizes the remaining time for the current slide.
*/
export function ThumbProgressCarousel({
items,
interval = 5000,
}: ThumbProgressCarouselProps) {
const [api, setApi] = React.useState<CarouselApi>();
const [current, setCurrent] = React.useState(0);
const [progress, setProgress] = React.useState(0);
const [isPaused, setIsPaused] = React.useState(false);
React.useEffect(() => {
if (!api) return;
const onSelect = () => {
setCurrent(api.selectedScrollSnap());
setProgress(0);
};
api.on('select', onSelect);
return () => {
api.off('select', onSelect);
};
}, [api]);
React.useEffect(() => {
if (!api || isPaused) return;
const tickRate = 50;
const step = 100 / (interval / tickRate);
const timer = setInterval(() => {
setProgress((prev) => {
if (prev >= 100) {
return 100;
}
return prev + step;
});
}, tickRate);
return () => clearInterval(timer);
}, [api, isPaused, interval]);
React.useEffect(() => {
if (api && progress >= 100) {
api.scrollNext();
}
}, [api, progress]);
const onThumbClick = (index: number) => {
if (!api) return;
api.scrollTo(index);
};
return (
<div
className='relative w-full max-w-5xl mx-auto group overflow-hidden rounded-xl bg-black'
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
>
<Carousel setApi={setApi} className='w-full' opts={{ loop: true }}>
<CarouselContent>
{items.map((item) => (
<CarouselItem key={item.id}>
<div className='relative aspect-[16/9] w-full overflow-hidden'>
<Image
src={item.image}
alt={item.title}
fill
className='object-cover transition-transform duration-700 ease-in-out group-hover:scale-105'
priority
/>
<div className='absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent pointer-events-none' />
<div className='absolute bottom-32 left-8 md:left-12 max-w-lg pointer-events-none'>
<h2 className='text-3xl md:text-5xl font-bold text-white tracking-tight drop-shadow-lg'>
{item.title}
</h2>
</div>
</div>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
<div className='absolute bottom-8 left-8 md:left-12 flex gap-3 z-10'>
{items.map((item, index) => {
const isActive = current === index;
return (
<button
key={item.id}
onClick={() => onThumbClick(index)}
className={cn(
'relative h-14 w-14 md:h-20 md:w-20 rounded-lg overflow-hidden border-2 transition-all duration-300 ease-out',
isActive
? 'border-primary border-4 shadow-lg scale-110'
: 'border-white/20 opacity-70 hover:opacity-100 hover:scale-105'
)}
>
<Image
src={item.image}
alt={`Go to slide ${index + 1}`}
fill
className='object-cover'
/>
{isActive && (
<div className='absolute inset-0 bg-black/40'>
<div
className='h-full bg-primary/40 absolute left-0 top-0 transition-all ease-linear'
style={{
width: `${progress}%`,
// GOTCHA: Disable transition when resetting to 0 to prevent the bar from "sliding backward" visibly.
transitionDuration: progress === 0 ? '0ms' : '50ms',
}}
/>
</div>
)}
</button>
);
})}
</div>
</div>
);
}Usage
To use the ThumbProgressCarousel, prepare an array of CarouselItemData objects. You can customize the auto-play speed using the interval prop.
import { ThumbProgressCarousel } from '@/components/thumb-progress-carousel';
const slides = [
{
id: '1',
image: 'https://images.unsplash.com/photo-1682687220742-aba13b6e50ba',
title: 'Majestic Mountains',
},
{
id: '2',
image: 'https://images.unsplash.com/photo-1682687220063-4742bd7fd538',
title: 'Urban Exploration',
},
{
id: '3',
image: 'https://images.unsplash.com/photo-1682687220199-d0124f48f95b',
title: 'Ocean Breeze',
},
];
export default function Example() {
return (
<div className='p-4 w-full flex justify-center'>
{/* Default interval (5000ms) */}
<ThumbProgressCarousel items={slides} />
{/* Custom fast interval (3000ms) */}
{/* <ThumbProgressCarousel items={slides} interval={3000} /> */}
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | CarouselItemData[] | Required | The array of slides to display. |
interval | number | 5000 | Auto-play interval duration in milliseconds. |