Slideshow
betaPhotos that crossfade one to the next on their own, each with a caption and a bar that fills while it shows.
Preview
default
wide
one photo
no photos
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/slideshow
Use it for
- A few photos that show what something looks like, like the walks, places, or rooms behind a feature, in a hero or beside a list.
- Three to six photos. Give each an alt that says what is in it, and a short title and detail for the caption.
- Pass picture for your own element instead of src, like a next/image.
- Use photos that fill the frame, with no clear parts. A new photo fades in over the one before, which shows through anything clear until the fade ends.
- Set a width. It keeps 4:5. Change the shape with className, like "aspect-video".
- Keep something beside it in step with onIndexChange.
- Change the time each photo shows with className="[--slideshow-duration:9s]". The default is 6s.
- Put a small Badge on the top corner with badge.
Avoid it for
- Photos people must compare or study. Use Media Grid, and Lightbox to view one large.
- Words or steps people must read in order. Nobody reads a slide that moves on by itself.
- Two slideshows on one screen. One moving thing is enough.
Props
- slides
Required. The photos, in order. It loops back to the first.
Slide[]
- Slide
Type. src is the photo. picture draws your own element in its place. alt says what is in it. title and detail make the caption.
{ src?: string; picture?: ReactNode; alt: string; title?: string; detail?: string; id?: string | number }
- badge
Something small on the top corner, like a Badge.
ReactNode
- onIndexChange
Called with the new photo's index each time it changes.
(index: number) => void
- texture
dither draws the photos as dots, gradient fades them from the top, and grain speckles them. Hover fills gradient and grain in. Left out, it follows the Texture around it.
"flat" | "dither" | "gradient" | "grain"
- aria-label
Names the slideshow for screen readers.
string · default "Slideshow"
- className
Extra classes on the root. Set the width, the shape, or --slideshow-duration here. Later classes win.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| slides | Slide[] | - | Required. The photos, in order. It loops back to the first. |
| Slide | { src?: string; picture?: ReactNode; alt: string; title?: string; detail?: string; id?: string | number } | - | Type. src is the photo. picture draws your own element in its place. alt says what is in it. title and detail make the caption. |
| badge | ReactNode | - | Something small on the top corner, like a Badge. |
| onIndexChange | (index: number) => void | - | Called with the new photo's index each time it changes. |
| texture | "flat" | "dither" | "gradient" | "grain" | - | dither draws the photos as dots, gradient fades them from the top, and grain speckles them. Hover fills gradient and grain in. Left out, it follows the Texture around it. |
| aria-label | string | "Slideshow" | Names the slideshow for screen readers. |
| className | string | - | Extra classes on the root. Set the width, the shape, or --slideshow-duration here. Later classes win. |
Accessibility
- The root is a carousel with a name. Each photo is a slide named like "2 of 4", and only the one showing reaches screen readers and Tab.
- It holds still while the pointer is over it or keyboard focus is inside it.
- A pause button stops it for good, on touch screens too. It says it is pressed with aria-pressed. While paused, a new caption is read aloud.
- Each bar is a button, like "Slide 3 of 4", so every photo can be reached without waiting. The one showing has aria-current.
- With prefers-reduced-motion, or data-motion="off", it stands still on the first photo, and the bars still change it. The pause button goes away then.
- The caption and controls sit on a dark veil in both themes, so they stay clear on light photos.
Examples
Photos with captions
<Slideshow
className="max-w-sm"
aria-label="Recent photos"
slides={[
{ src: "/photos/columns.jpg", alt: "Stone columns at dusk", title: "Columns at dusk", detail: "East gallery · Yesterday" },
{ src: "/photos/lake.jpg", alt: "A still lake at sunrise", title: "Lake at first light", detail: "North shore · 3h ago" },
{ src: "/photos/arcade.jpg", alt: "Three arches in a stone wall", title: "Arcade at noon", detail: "Main square · Today" },
]}
/><Slideshow
texture="dither"
className="max-w-sm"
aria-label="Recent photos"
slides={[
{ src: "/photos/columns.jpg", alt: "Stone columns at dusk", title: "Columns at dusk", detail: "East gallery · Yesterday" },
{ src: "/photos/lake.jpg", alt: "A still lake at sunrise", title: "Lake at first light", detail: "North shore · 3h ago" },
{ src: "/photos/arcade.jpg", alt: "Three arches in a stone wall", title: "Arcade at noon", detail: "Main square · Today" },
]}
/><Slideshow
texture="gradient"
className="max-w-sm"
aria-label="Recent photos"
slides={[
{ src: "/photos/columns.jpg", alt: "Stone columns at dusk", title: "Columns at dusk", detail: "East gallery · Yesterday" },
{ src: "/photos/lake.jpg", alt: "A still lake at sunrise", title: "Lake at first light", detail: "North shore · 3h ago" },
{ src: "/photos/arcade.jpg", alt: "Three arches in a stone wall", title: "Arcade at noon", detail: "Main square · Today" },
]}
/><Slideshow
texture="grain"
className="max-w-sm"
aria-label="Recent photos"
slides={[
{ src: "/photos/columns.jpg", alt: "Stone columns at dusk", title: "Columns at dusk", detail: "East gallery · Yesterday" },
{ src: "/photos/lake.jpg", alt: "A still lake at sunrise", title: "Lake at first light", detail: "North shore · 3h ago" },
{ src: "/photos/arcade.jpg", alt: "Three arches in a stone wall", title: "Arcade at noon", detail: "Main square · Today" },
]}
/>A list beside it that follows the photo, with a badge and slower slides
const [index, setIndex] = useState(0);
<div className="flex gap-4">
<Slideshow
slides={slides}
badge={<Badge emphasis="soft" size="sm">New</Badge>}
onIndexChange={setIndex}
className="max-w-xs [--slideshow-duration:9s]"
/>
<ul>
{slides.map((slide, i) => (
<li key={slide.src} aria-current={i === index ? "true" : undefined}>{slide.title}</li>
))}
</ul>
</div>const [index, setIndex] = useState(0);
<div className="flex gap-4">
<Slideshow
texture="dither"
slides={slides}
badge={<Badge emphasis="soft" size="sm">New</Badge>}
onIndexChange={setIndex}
className="max-w-xs [--slideshow-duration:9s]"
/>
<ul>
{slides.map((slide, i) => (
<li key={slide.src} aria-current={i === index ? "true" : undefined}>{slide.title}</li>
))}
</ul>
</div>const [index, setIndex] = useState(0);
<div className="flex gap-4">
<Slideshow
texture="gradient"
slides={slides}
badge={<Badge emphasis="soft" size="sm">New</Badge>}
onIndexChange={setIndex}
className="max-w-xs [--slideshow-duration:9s]"
/>
<ul>
{slides.map((slide, i) => (
<li key={slide.src} aria-current={i === index ? "true" : undefined}>{slide.title}</li>
))}
</ul>
</div>const [index, setIndex] = useState(0);
<div className="flex gap-4">
<Slideshow
texture="grain"
slides={slides}
badge={<Badge emphasis="soft" size="sm">New</Badge>}
onIndexChange={setIndex}
className="max-w-xs [--slideshow-duration:9s]"
/>
<ul>
{slides.map((slide, i) => (
<li key={slide.src} aria-current={i === index ? "true" : undefined}>{slide.title}</li>
))}
</ul>
</div>Photos drawn as dots
<Slideshow slides={slides} texture="dither" className="max-w-sm" />Grain photos that clear on hover
<Slideshow slides={slides} texture="grain" className="max-w-sm" />