component
A scroll-snap carousel for Astro, Svelte, and React. Scroll position is the source of truth — native CSS scroll-snap drives all swipe and scroll behavior without JS hijacking. The component works with or without the optional prev/next buttons, dot indicators, and autoplay.
Start here: swipe, trackpad-pan, or wheel-scroll the carousel. Dots sync with the visible slide via IntersectionObserver, and keyboard navigation (arrow keys, Home, End) is wired to the viewport when focused.
The viewport is the source of truth: native CSS scroll-snapdrives all swipe and scroll behavior without JS hijacking. Try swiping on touch, trackpad-panning, or just using the scroll wheel — the component stays in sync via IntersectionObserver.
---
import Carousel from "../Carousel.astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const slide = css({
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "200px",
width: "100%",
borderRadius: "md",
fontSize: "3xl",
fontWeight: "bold",
background: "colorPalette",
color: "colorPalette.text.contrast.bold",
});
---
<div class={stack({ gap: "xl", direction: "column" })}>
<div>
<h3>Scroll-snap carousel</h3>
<p>
The viewport is the source of truth: native CSS <code>scroll-snap</code>
drives all swipe and scroll behavior without JS hijacking. Try swiping on touch,
trackpad-panning, or just using the scroll wheel — the component stays in sync
via <code>IntersectionObserver</code>.
</p>
<Carousel
label="Example carousel"
controls
indicators
items={[
{ value: "one", label: "Slide 1" },
{ value: "two", label: "Slide 2" },
{ value: "three", label: "Slide 3" },
{ value: "four", label: "Slide 4" },
{ value: "five", label: "Slide 5" },
]}
>
<Fragment slot="one">
<div class={`${slide} ${css({ colorPalette: "primary" })}`}>1</div>
</Fragment>
<Fragment slot="two">
<div class={`${slide} ${css({ colorPalette: "success" })}`}>2</div>
</Fragment>
<Fragment slot="three">
<div class={`${slide} ${css({ colorPalette: "warning" })}`}>3</div>
</Fragment>
<Fragment slot="four">
<div class={`${slide} ${css({ colorPalette: "danger" })}`}>4</div>
</Fragment>
<Fragment slot="five">
<div class={`${slide} ${css({ colorPalette: "neutral" })}`}>5</div>
</Fragment>
</Carousel>
</div>
</div>Use orientation="horizontal" (default) or "vertical" to flip the scroll and snap axis. Vertical carousels bound their viewport height so scrolling stays contained within the component.
Snapping along the x-axis. Arrow keys navigate left/right when the viewport is focused.
Flips the snap axis to y. Viewport gets a bounded height so scrolling is contained; arrow keys switch to up/down.
---
import Carousel from "../Carousel.astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const palettes = ["primary", "success", "warning", "danger", "neutral"];
const base = css({
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
minHeight: "200px",
minWidth: "100%",
borderRadius: "md",
fontWeight: "bold",
fontSize: "xl",
background: "colorPalette",
color: "colorPalette.text.contrast.bold",
});
const tile = (i: number) =>
`${base} ${css({ colorPalette: palettes[i % palettes.length] })}`;
---
<div class={stack({ gap: "xl", direction: "column" })}>
<div>
<h3>Horizontal (default)</h3>
<p>
Snapping along the x-axis. Arrow keys navigate left/right when the
viewport is focused.
</p>
<Carousel
orientation="horizontal"
label="Horizontal carousel"
controls
indicators
items={[
{ value: "h1", label: "H1" },
{ value: "h2", label: "H2" },
{ value: "h3", label: "H3" },
{ value: "h4", label: "H4" },
]}
>
<Fragment slot="h1"><div class={tile(0)}>1</div></Fragment>
<Fragment slot="h2"><div class={tile(1)}>2</div></Fragment>
<Fragment slot="h3"><div class={tile(2)}>3</div></Fragment>
<Fragment slot="h4"><div class={tile(3)}>4</div></Fragment>
</Carousel>
</div>
<div>
<h3>Vertical</h3>
<p>
Flips the snap axis to y. Viewport gets a bounded height so scrolling is
contained; arrow keys switch to up/down.
</p>
<Carousel
orientation="vertical"
label="Vertical carousel"
controls
indicators
items={[
{ value: "v1", label: "V1" },
{ value: "v2", label: "V2" },
{ value: "v3", label: "V3" },
{ value: "v4", label: "V4" },
]}
>
<Fragment slot="v1"><div class={tile(0)}>1</div></Fragment>
<Fragment slot="v2"><div class={tile(1)}>2</div></Fragment>
<Fragment slot="v3"><div class={tile(2)}>3</div></Fragment>
<Fragment slot="v4"><div class={tile(3)}>4</div></Fragment>
</Carousel>
</div>
</div>Control how many slides share the viewport with slidesPerView. Use 1 for full-width slides, 2 or 3 for grid-like layouts, or "auto" to let each slide size itself.
Each slide fills the viewport.
slidesPerView=2Two slides share the viewport; snap keeps pairs aligned.
slidesPerView=3Three per row — the classic product-carousel layout.
slidesPerView="auto"Items size themselves — useful when cards have intrinsic widths. Mix different widths freely.
slidesPerView works the same way along the y-axis.
---
import Carousel from "../Carousel.astro";
import { css } from "@pindoba/styled-system/css";
import { stack, flex } from "@pindoba/styled-system/patterns";
const items = Array.from({ length: 8 }, (_, i) => ({
value: `s${i + 1}`,
label: `Slide ${i + 1}`,
}));
const palettes = ["primary", "success", "warning", "danger", "neutral"];
const base = css({
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "140px",
borderRadius: "md",
fontWeight: "bold",
fontSize: "xl",
background: "colorPalette",
color: "colorPalette.text.contrast.bold",
});
const tile = (i: number) =>
`${base} ${css({ colorPalette: palettes[i % palettes.length] })}`;
const vBase = css({
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
width: "100%",
minHeight: "120px",
borderRadius: "md",
fontWeight: "bold",
fontSize: "xl",
background: "colorPalette",
color: "colorPalette.text.contrast.bold",
});
const vTile = (i: number) =>
`${vBase} ${css({ colorPalette: palettes[i % palettes.length] })}`;
const autoBase = css({
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "140px",
width: "240px",
borderRadius: "md",
fontWeight: "bold",
fontSize: "xl",
background: "colorPalette",
color: "colorPalette.text.contrast.bold",
});
const autoTile = (i: number) =>
`${autoBase} ${css({ colorPalette: palettes[i % palettes.length] })}`;
---
<div class={stack({ gap: "xl", direction: "column" })}>
<div>
<h3>One per view (default)</h3>
<p>Each slide fills the viewport.</p>
<Carousel label="One per view" controls indicators items={items}>
<Fragment slot="s1"><div class={tile(0)}>1</div></Fragment>
<Fragment slot="s2"><div class={tile(1)}>2</div></Fragment>
<Fragment slot="s3"><div class={tile(2)}>3</div></Fragment>
<Fragment slot="s4"><div class={tile(3)}>4</div></Fragment>
<Fragment slot="s5"><div class={tile(4)}>5</div></Fragment>
<Fragment slot="s6"><div class={tile(0)}>6</div></Fragment>
<Fragment slot="s7"><div class={tile(1)}>7</div></Fragment>
<Fragment slot="s8"><div class={tile(2)}>8</div></Fragment>
</Carousel>
</div>
<div>
<h3><code>slidesPerView={2}</code></h3>
<p>Two slides share the viewport; snap keeps pairs aligned.</p>
<Carousel label="Two per view" slidesPerView={2} controls items={items}>
<Fragment slot="s1"><div class={tile(0)}>1</div></Fragment>
<Fragment slot="s2"><div class={tile(1)}>2</div></Fragment>
<Fragment slot="s3"><div class={tile(2)}>3</div></Fragment>
<Fragment slot="s4"><div class={tile(3)}>4</div></Fragment>
<Fragment slot="s5"><div class={tile(4)}>5</div></Fragment>
<Fragment slot="s6"><div class={tile(0)}>6</div></Fragment>
<Fragment slot="s7"><div class={tile(1)}>7</div></Fragment>
<Fragment slot="s8"><div class={tile(2)}>8</div></Fragment>
</Carousel>
</div>
<div>
<h3><code>slidesPerView={3}</code></h3>
<p>Three per row — the classic product-carousel layout.</p>
<Carousel label="Three per view" slidesPerView={3} controls items={items}>
<Fragment slot="s1"><div class={tile(0)}>1</div></Fragment>
<Fragment slot="s2"><div class={tile(1)}>2</div></Fragment>
<Fragment slot="s3"><div class={tile(2)}>3</div></Fragment>
<Fragment slot="s4"><div class={tile(3)}>4</div></Fragment>
<Fragment slot="s5"><div class={tile(4)}>5</div></Fragment>
<Fragment slot="s6"><div class={tile(0)}>6</div></Fragment>
<Fragment slot="s7"><div class={tile(1)}>7</div></Fragment>
<Fragment slot="s8"><div class={tile(2)}>8</div></Fragment>
</Carousel>
</div>
<div>
<h3><code>slidesPerView="auto"</code></h3>
<p>
Items size themselves — useful when cards have intrinsic widths. Mix
different widths freely.
</p>
<Carousel label="Auto per view" slidesPerView="auto" controls items={items}>
<Fragment slot="s1"><div class={autoTile(0)}>1</div></Fragment>
<Fragment slot="s2"><div class={autoTile(1)}>2</div></Fragment>
<Fragment slot="s3"><div class={autoTile(2)}>3</div></Fragment>
<Fragment slot="s4"><div class={autoTile(3)}>4</div></Fragment>
<Fragment slot="s5"><div class={autoTile(4)}>5</div></Fragment>
<Fragment slot="s6"><div class={autoTile(0)}>6</div></Fragment>
<Fragment slot="s7"><div class={autoTile(1)}>7</div></Fragment>
<Fragment slot="s8"><div class={autoTile(2)}>8</div></Fragment>
</Carousel>
</div>
<div>
<h3>Vertical orientation</h3>
<p>
<code>slidesPerView</code> works the same way along the y-axis.
</p>
<div class={flex({ gap: "md", wrap: "wrap" })}>
<div style="flex: 1 1 0; min-width: 200px">
<Carousel
label="Two per view vertical"
orientation="vertical"
slidesPerView={2}
controls
items={items}
>
<Fragment slot="s1"><div class={vTile(0)}>1</div></Fragment>
<Fragment slot="s2"><div class={vTile(1)}>2</div></Fragment>
<Fragment slot="s3"><div class={vTile(2)}>3</div></Fragment>
<Fragment slot="s4"><div class={vTile(3)}>4</div></Fragment>
<Fragment slot="s5"><div class={vTile(4)}>5</div></Fragment>
<Fragment slot="s6"><div class={vTile(0)}>6</div></Fragment>
<Fragment slot="s7"><div class={vTile(1)}>7</div></Fragment>
<Fragment slot="s8"><div class={vTile(2)}>8</div></Fragment>
</Carousel>
</div>
<div style="flex: 1 1 0; min-width: 200px">
<Carousel
label="Three per view vertical"
orientation="vertical"
slidesPerView={3}
controls
items={items}
>
<Fragment slot="s1"><div class={vTile(0)}>1</div></Fragment>
<Fragment slot="s2"><div class={vTile(1)}>2</div></Fragment>
<Fragment slot="s3"><div class={vTile(2)}>3</div></Fragment>
<Fragment slot="s4"><div class={vTile(3)}>4</div></Fragment>
<Fragment slot="s5"><div class={vTile(4)}>5</div></Fragment>
<Fragment slot="s6"><div class={vTile(0)}>6</div></Fragment>
<Fragment slot="s7"><div class={vTile(1)}>7</div></Fragment>
<Fragment slot="s8"><div class={vTile(2)}>8</div></Fragment>
</Carousel>
</div>
</div>
</div>
</div>size tunes the gap between slides and the size of indicator dots. The prop cascades to items through CSS custom properties, so children inherit the same spacing without prop drilling.
Tighter gap and smaller indicator dots.
Size cascades to items through CSS custom properties — children inherit gap and indicator size without prop drilling.
---
import Carousel from "../Carousel.astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const items = [
{ value: "a", label: "Slide A" },
{ value: "b", label: "Slide B" },
{ value: "c", label: "Slide C" },
{ value: "d", label: "Slide D" },
];
const palettes = ["primary", "success", "warning", "danger", "neutral"];
const base = css({
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "140px",
borderRadius: "md",
fontWeight: "bold",
fontSize: "xl",
background: "colorPalette",
color: "colorPalette.text.contrast.bold",
});
const tile = (i: number) =>
`${base} ${css({ colorPalette: palettes[i % palettes.length] })}`;
---
<div class={stack({ gap: "xl", direction: "column" })}>
<div>
<h3>Small</h3>
<p>Tighter gap and smaller indicator dots.</p>
<Carousel size="sm" label="Small" controls indicators items={items}>
<Fragment slot="a"><div class={tile(0)}>A</div></Fragment>
<Fragment slot="b"><div class={tile(1)}>B</div></Fragment>
<Fragment slot="c"><div class={tile(2)}>C</div></Fragment>
<Fragment slot="d"><div class={tile(3)}>D</div></Fragment>
</Carousel>
</div>
<div>
<h3>Medium (default)</h3>
<Carousel size="md" label="Medium" controls indicators items={items}>
<Fragment slot="a"><div class={tile(0)}>A</div></Fragment>
<Fragment slot="b"><div class={tile(1)}>B</div></Fragment>
<Fragment slot="c"><div class={tile(2)}>C</div></Fragment>
<Fragment slot="d"><div class={tile(3)}>D</div></Fragment>
</Carousel>
</div>
<div>
<h3>Large</h3>
<p>
Size cascades to items through CSS custom properties — children inherit
gap and indicator size without prop drilling.
</p>
<Carousel size="lg" label="Large" controls indicators items={items}>
<Fragment slot="a"><div class={tile(0)}>A</div></Fragment>
<Fragment slot="b"><div class={tile(1)}>B</div></Fragment>
<Fragment slot="c"><div class={tile(2)}>C</div></Fragment>
<Fragment slot="d"><div class={tile(3)}>D</div></Fragment>
</Carousel>
</div>
</div>Enable controls to render circular Prev/Next buttons that reuse the Pindoba button component. Buttons auto-disable at the ends unless loop is set.
Optional controls render circular Prev/Next buttons that reuse@pindoba/astro-button. Buttons auto-disable at the ends when loop is off.
Set loop to wrap past the last slide back to the first. Buttons remain enabled at both ends.
---
import Carousel from "../Carousel.astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const items = [
{ value: "one", label: "Slide 1" },
{ value: "two", label: "Slide 2" },
{ value: "three", label: "Slide 3" },
{ value: "four", label: "Slide 4" },
];
const palettes = ["primary", "success", "warning", "danger", "neutral"];
const base = css({
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "160px",
borderRadius: "md",
fontSize: "2xl",
fontWeight: "bold",
background: "colorPalette",
color: "colorPalette.text.contrast.bold",
});
const tile = (i: number) =>
`${base} ${css({ colorPalette: palettes[i % palettes.length] })}`;
---
<div class={stack({ gap: "xl", direction: "column" })}>
<div>
<h3>Prev / Next buttons</h3>
<p>
Optional <code>controls</code> render circular Prev/Next buttons that reuse
<code>@pindoba/astro-button</code>. Buttons auto-disable at the ends when <code
>loop</code
> is off.
</p>
<Carousel label="Controls example" controls items={items}>
<Fragment slot="one"><div class={tile(0)}>1</div></Fragment>
<Fragment slot="two"><div class={tile(1)}>2</div></Fragment>
<Fragment slot="three"><div class={tile(2)}>3</div></Fragment>
<Fragment slot="four"><div class={tile(3)}>4</div></Fragment>
</Carousel>
</div>
<div>
<h3>Looping controls</h3>
<p>
Set <code>loop</code> to wrap past the last slide back to the first. Buttons
remain enabled at both ends.
</p>
<Carousel label="Looping controls" controls loop items={items}>
<Fragment slot="one"><div class={tile(0)}>1</div></Fragment>
<Fragment slot="two"><div class={tile(1)}>2</div></Fragment>
<Fragment slot="three"><div class={tile(2)}>3</div></Fragment>
<Fragment slot="four"><div class={tile(3)}>4</div></Fragment>
</Carousel>
</div>
</div>Set indicators to render a tablist of dots. Active state tracks scroll position, and clicking a dot smoothly scrolls to that slide.
Indicators double as a jump-to-slide control and a progress marker. They sync with scroll position via IntersectionObserver, so the active dot always matches the slide most in view.
Combine both for full-featured navigation.
---
import Carousel from "../Carousel.astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const items = [
{ value: "one", label: "Slide 1" },
{ value: "two", label: "Slide 2" },
{ value: "three", label: "Slide 3" },
{ value: "four", label: "Slide 4" },
{ value: "five", label: "Slide 5" },
];
const palettes = ["primary", "success", "warning", "danger", "neutral"];
const base = css({
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "160px",
borderRadius: "md",
fontSize: "2xl",
fontWeight: "bold",
background: "colorPalette",
color: "colorPalette.text.contrast.bold",
});
const tile = (i: number) =>
`${base} ${css({ colorPalette: palettes[i % palettes.length] })}`;
---
<div class={stack({ gap: "xl", direction: "column" })}>
<div>
<h3>Dot indicators</h3>
<p>
Indicators double as a jump-to-slide control and a progress marker. They
sync with scroll position via <code>IntersectionObserver</code>, so the
active dot always matches the slide most in view.
</p>
<Carousel label="With indicators" indicators items={items}>
<Fragment slot="one"><div class={tile(0)}>1</div></Fragment>
<Fragment slot="two"><div class={tile(1)}>2</div></Fragment>
<Fragment slot="three"><div class={tile(2)}>3</div></Fragment>
<Fragment slot="four"><div class={tile(3)}>4</div></Fragment>
<Fragment slot="five"><div class={tile(4)}>5</div></Fragment>
</Carousel>
</div>
<div>
<h3>Indicators + controls</h3>
<p>Combine both for full-featured navigation.</p>
<Carousel label="Full navigation" controls indicators items={items}>
<Fragment slot="one"><div class={tile(0)}>1</div></Fragment>
<Fragment slot="two"><div class={tile(1)}>2</div></Fragment>
<Fragment slot="three"><div class={tile(2)}>3</div></Fragment>
<Fragment slot="four"><div class={tile(3)}>4</div></Fragment>
<Fragment slot="five"><div class={tile(4)}>5</div></Fragment>
</Carousel>
</div>
</div>Pass a number of milliseconds to autoplay to auto-advance slides. Autoplay pauses on hover, focus-within, tab-hidden, and prefers-reduced-motion: reduce — respecting the platform by default.
loopAutoplay advances every 3000 ms. It pauses on hover, focus-within, tab-hidden, and when the user hasprefers-reduced-motion: reduce — respecting the platform by default.
---
import Carousel from "../Carousel.astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const items = [
{ value: "one", label: "Slide 1" },
{ value: "two", label: "Slide 2" },
{ value: "three", label: "Slide 3" },
{ value: "four", label: "Slide 4" },
];
const palettes = ["primary", "success", "warning", "danger", "neutral"];
const base = css({
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "200px",
borderRadius: "md",
fontSize: "2xl",
fontWeight: "bold",
background: "colorPalette",
color: "colorPalette.text.contrast.bold",
});
const tile = (i: number) =>
`${base} ${css({ colorPalette: palettes[i % palettes.length] })}`;
---
<div class={stack({ gap: "xl", direction: "column" })}>
<div>
<h3>Autoplay with <code>loop</code></h3>
<p>
Autoplay advances every <code>3000</code> ms. It pauses on hover, focus-within,
tab-hidden, and when the user has
<code>prefers-reduced-motion: reduce</code> — respecting the platform by default.
</p>
<Carousel
label="Autoplay carousel"
controls
indicators
loop
autoplay={3000}
items={items}
>
<Fragment slot="one"><div class={tile(0)}>1</div></Fragment>
<Fragment slot="two"><div class={tile(1)}>2</div></Fragment>
<Fragment slot="three"><div class={tile(2)}>3</div></Fragment>
<Fragment slot="four"><div class={tile(3)}>4</div></Fragment>
</Carousel>
</div>
</div>align controls where each slide snaps within the viewport: "start" (default), "center" for the classic “peek” pattern, or "end" for trailing-edge alignment.
align="start" (default)Snap anchor at the leading edge.
align="center"Each slide snaps to the center of the viewport — the common product-carousel "peek" pattern.
align="end"Snap anchor at the trailing edge.
Snap alignment works identically along the y-axis. Scroll each to see where the slides anchor.
---
import Carousel from "../Carousel.astro";
import { css } from "@pindoba/styled-system/css";
import { stack, flex } from "@pindoba/styled-system/patterns";
const items = [
{ value: "s1", label: "1" },
{ value: "s2", label: "2" },
{ value: "s3", label: "3" },
{ value: "s4", label: "4" },
{ value: "s5", label: "5" },
{ value: "s6", label: "6" },
];
const palettes = ["primary", "success", "warning", "danger", "neutral"];
const base = css({
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "180px",
borderRadius: "md",
fontSize: "2xl",
fontWeight: "bold",
background: "colorPalette",
color: "colorPalette.text.contrast.bold",
});
const tile = (i: number) =>
`${base} ${css({ colorPalette: palettes[i % palettes.length] })}`;
const tileStyle = "width: 440px;";
const vBase = css({
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "180px",
width: "100%",
borderRadius: "md",
fontSize: "xl",
fontWeight: "bold",
background: "colorPalette",
color: "colorPalette.text.contrast.bold",
});
const vTile = (i: number) =>
`${vBase} ${css({ colorPalette: palettes[i % palettes.length] })}`;
---
<div class={stack({ gap: "xl", direction: "column" })}>
<div>
<h3><code>align="start"</code> (default)</h3>
<p>Snap anchor at the leading edge.</p>
<Carousel
label="Align start"
slidesPerView="auto"
align="start"
controls
items={items}
>
<Fragment slot="s1"
><div class={tile(0)} style={tileStyle}>1</div></Fragment
>
<Fragment slot="s2"
><div class={tile(1)} style={tileStyle}>2</div></Fragment
>
<Fragment slot="s3"
><div class={tile(2)} style={tileStyle}>3</div></Fragment
>
<Fragment slot="s4"
><div class={tile(3)} style={tileStyle}>4</div></Fragment
>
<Fragment slot="s5"
><div class={tile(4)} style={tileStyle}>5</div></Fragment
>
<Fragment slot="s6"
><div class={tile(0)} style={tileStyle}>6</div></Fragment
>
</Carousel>
</div>
<div>
<h3><code>align="center"</code></h3>
<p>
Each slide snaps to the center of the viewport — the common
product-carousel "peek" pattern.
</p>
<Carousel
label="Align center"
slidesPerView="auto"
align="center"
controls
items={items}
>
<Fragment slot="s1"
><div class={tile(0)} style={tileStyle}>1</div></Fragment
>
<Fragment slot="s2"
><div class={tile(1)} style={tileStyle}>2</div></Fragment
>
<Fragment slot="s3"
><div class={tile(2)} style={tileStyle}>3</div></Fragment
>
<Fragment slot="s4"
><div class={tile(3)} style={tileStyle}>4</div></Fragment
>
<Fragment slot="s5"
><div class={tile(4)} style={tileStyle}>5</div></Fragment
>
<Fragment slot="s6"
><div class={tile(0)} style={tileStyle}>6</div></Fragment
>
</Carousel>
</div>
<div>
<h3><code>align="end"</code></h3>
<p>Snap anchor at the trailing edge.</p>
<Carousel
label="Align end"
slidesPerView="auto"
align="end"
controls
items={items}
>
<Fragment slot="s1"
><div class={tile(0)} style={tileStyle}>1</div></Fragment
>
<Fragment slot="s2"
><div class={tile(1)} style={tileStyle}>2</div></Fragment
>
<Fragment slot="s3"
><div class={tile(2)} style={tileStyle}>3</div></Fragment
>
<Fragment slot="s4"
><div class={tile(3)} style={tileStyle}>4</div></Fragment
>
<Fragment slot="s5"
><div class={tile(4)} style={tileStyle}>5</div></Fragment
>
<Fragment slot="s6"
><div class={tile(0)} style={tileStyle}>6</div></Fragment
>
</Carousel>
</div>
<div>
<h3>Vertical orientation</h3>
<p>
Snap alignment works identically along the y-axis. Scroll each to see
where the slides anchor.
</p>
<div class={flex({ gap: "md", wrap: "wrap" })}>
<div style="flex: 1 1 0; min-width: 140px">
<Carousel
label="Align start vertical"
orientation="vertical"
slidesPerView="auto"
align="start"
controls
items={items}
>
<Fragment slot="s1"><div class={vTile(0)}>1</div></Fragment>
<Fragment slot="s2"><div class={vTile(1)}>2</div></Fragment>
<Fragment slot="s3"><div class={vTile(2)}>3</div></Fragment>
<Fragment slot="s4"><div class={vTile(3)}>4</div></Fragment>
<Fragment slot="s5"><div class={vTile(4)}>5</div></Fragment>
<Fragment slot="s6"><div class={vTile(0)}>6</div></Fragment>
</Carousel>
</div>
<div style="flex: 1 1 0; min-width: 140px">
<Carousel
label="Align center vertical"
orientation="vertical"
slidesPerView="auto"
align="center"
controls
items={items}
>
<Fragment slot="s1"><div class={vTile(0)}>1</div></Fragment>
<Fragment slot="s2"><div class={vTile(1)}>2</div></Fragment>
<Fragment slot="s3"><div class={vTile(2)}>3</div></Fragment>
<Fragment slot="s4"><div class={vTile(3)}>4</div></Fragment>
<Fragment slot="s5"><div class={vTile(4)}>5</div></Fragment>
<Fragment slot="s6"><div class={vTile(0)}>6</div></Fragment>
</Carousel>
</div>
<div style="flex: 1 1 0; min-width: 140px">
<Carousel
label="Align end vertical"
orientation="vertical"
slidesPerView="auto"
align="end"
controls
items={items}
>
<Fragment slot="s1"><div class={vTile(0)}>1</div></Fragment>
<Fragment slot="s2"><div class={vTile(1)}>2</div></Fragment>
<Fragment slot="s3"><div class={vTile(2)}>3</div></Fragment>
<Fragment slot="s4"><div class={vTile(3)}>4</div></Fragment>
<Fragment slot="s5"><div class={vTile(4)}>5</div></Fragment>
<Fragment slot="s6"><div class={vTile(0)}>6</div></Fragment>
</Carousel>
</div>
</div>
</div>
</div>Two APIs are supported: a data-driven items array with named slots/snippets keyed by value, and direct <CarouselItem> child composition for when each slide needs bespoke markup. When both are provided, items wins.
Pass an items prop and a named slot per item (keyed by the item'svalue). Best for data that comes from a backend, a CMS, or a static list.
Drop <CarouselItem> children directly into the carousel for maximum flexibility — ideal when each slide has bespoke markup or different structure. When both items and children are provided, items wins.
---
import Carousel from "../Carousel.astro";
import CarouselItem from "../CarouselItem.astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const palettes = ["primary", "success", "warning", "danger", "neutral"];
const base = css({
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "160px",
borderRadius: "md",
fontWeight: "bold",
fontSize: "xl",
background: "colorPalette",
color: "colorPalette.text.contrast.bold",
});
const tile = (i: number) =>
`${base} ${css({ colorPalette: palettes[i % palettes.length] })}`;
---
<div class={stack({ gap: "xl", direction: "column" })}>
<div>
<h3>Items array (data-driven)</h3>
<p>
Pass an <code>items</code> prop and a named slot per item (keyed by the item's
<code>value</code>). Best for data that comes from a backend, a CMS, or a
static list.
</p>
<Carousel
label="Items API"
controls
indicators
items={[
{ value: "one", label: "Slide 1" },
{ value: "two", label: "Slide 2" },
{ value: "three", label: "Slide 3" },
]}
>
<Fragment slot="one"><div class={tile(0)}>1</div></Fragment>
<Fragment slot="two"><div class={tile(1)}>2</div></Fragment>
<Fragment slot="three"><div class={tile(2)}>3</div></Fragment>
</Carousel>
</div>
<div>
<h3>Slot composition</h3>
<p>
Drop <code><CarouselItem></code> children directly into the carousel for
maximum flexibility — ideal when each slide has bespoke markup or different
structure. When both <code>items</code> and children are provided, items wins.
</p>
<Carousel label="Slot API" controls>
<CarouselItem value="a"><div class={tile(0)}>First</div></CarouselItem>
<CarouselItem value="b"><div class={tile(1)}>Second</div></CarouselItem>
<CarouselItem value="c"><div class={tile(2)}>Third</div></CarouselItem>
</Carousel>
</div>
</div>The carousel root is a Panel root. It defaults to a transparent, unpadded, square-cornered layout wrapper — a plain <Carousel> paints nothing — but every Panel surface prop is available: background, emphasis, feedback, padding, radius (plus the per-side variants), border, shadow, translucent and as.
Pass background + padding + radius="inner" and the carousel becomes a recessed tray whose corner is concentric with the Card (or Dialog, or any Panel) around it — inner resolves as the parent’s radius minus its padding.
The carousel root is a Panel. It paints nothing by default, but passbackground, padding and radius="inner"and it becomes a recessed tray whose corner is concentric with the Card around it.
---
import Carousel from "../Carousel.astro";
import Card from "@pindoba/astro-card";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const slide = css({
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "160px",
width: "100%",
borderRadius: "md",
fontSize: "3xl",
fontWeight: "bold",
background: "colorPalette",
color: "colorPalette.text.contrast.bold",
});
const items = [
{ value: "one", label: "Slide 1" },
{ value: "two", label: "Slide 2" },
{ value: "three", label: "Slide 3" },
];
---
<div class={stack({ gap: "md", direction: "column", width: "full" })}>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
The carousel root is a Panel. It paints nothing by default, but pass
<code>background</code>, <code>padding</code> and <code>radius="inner"</code
>
and it becomes a recessed tray whose corner is concentric with the Card around
it.
</p>
<Card size="md" radius="2xl" border="default">
<Carousel
label="Concentric carousel"
controls
indicators
background="surface.ground"
padding="sm"
radius="inner"
items={items}
>
<Fragment slot="one">
<div class={`${slide} ${css({ colorPalette: "primary" })}`}>1</div>
</Fragment>
<Fragment slot="two">
<div class={`${slide} ${css({ colorPalette: "success" })}`}>2</div>
</Fragment>
<Fragment slot="three">
<div class={`${slide} ${css({ colorPalette: "warning" })}`}>3</div>
</Fragment>
</Carousel>
</Card>
</div>The passThrough prop targets any slot (root, viewport, track, item, controls, prevButton, nextButton, indicators, indicator, liveRegion). Each slot accepts a style (Panda SystemStyleObject) and props (HTML attributes).
Use passThrough to target any slot. styleaccepts a Panda SystemStyleObject; propsforwards arbitrary HTML attributes — useful for overridingaria-label, id, or data attributes.
---
import Carousel from "../Carousel.astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const items = [
{ value: "one", label: "Slide 1" },
{ value: "two", label: "Slide 2" },
{ value: "three", label: "Slide 3" },
];
const palettes = ["primary", "success", "warning", "danger", "neutral"];
const base = css({
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "180px",
borderRadius: "md",
fontSize: "2xl",
fontWeight: "bold",
background: "colorPalette",
color: "colorPalette.text.contrast.bold",
});
const tile = (i: number) =>
`${base} ${css({ colorPalette: palettes[i % palettes.length] })}`;
---
<div class={stack({ gap: "xl", direction: "column" })}>
<div>
<h3>Style + attribute overrides</h3>
<p>
Use <code>passThrough</code> to target any slot. <code>style</code>
accepts a Panda <code>SystemStyleObject</code>; <code>props</code>
forwards arbitrary HTML attributes — useful for overriding
<code>aria-label</code>, <code>id</code>, or data attributes.
</p>
<Carousel
label="Custom-styled carousel"
controls
indicators
items={items}
passThrough={{
root: {
style: { maxWidth: "640px", mx: "auto" },
props: { "data-testid": "showcase-carousel" },
},
viewport: {
style: {
borderWidth: "1px",
borderColor: "border.default",
borderRadius: "lg",
},
},
indicator: {
style: { width: "12px", height: "12px" },
},
}}
>
<Fragment slot="one"><div class={tile(0)}>1</div></Fragment>
<Fragment slot="two"><div class={tile(1)}>2</div></Fragment>
<Fragment slot="three"><div class={tile(2)}>3</div></Fragment>
</Carousel>
</div>
</div>The viewport is a labelled carousel region (role="region", aria-roledescription="carousel", tabindex="0") — pass a label so screen-reader users know what the carousel contains. Arrow keys move between slides (ArrowLeft/ArrowRight when horizontal, ArrowUp/ArrowDown when vertical), and Home/End jump to the first/last slide.
A visually-hidden polite live region announces the active slide (“Slide 2 of 6”, or the slide’s label) as it changes — never on initial load.
Set inertOffscreenSlides to mark slides scrolled out of view as aria-hidden + inert, so assistive tech and the tab order skip them. It’s off by default because inert changes focus reachability — opt in when each slide contains focusable content.
RTL is supported out of the box: render the carousel inside a dir="rtl" context and the scroll math, prev/next direction, edge state, and chevron icons all mirror automatically (the engine reads the computed direction — no prop needed).
For a vertical carousel, the viewport height defaults to 420px. Override it with the --_carousel-viewport-h CSS custom property (e.g. passThrough={{ viewport: { style: { "--_carousel-viewport-h": "320px" } } }}).
falseMark this as the chosen one in a set (a current nav item, a checked option, a selected card). Where `interactive` says it *can* be clicked, `active` says it *is* the current choice. Use `activeEmphasis` to control how loud the state is. Combined with `interactive`, hover and press restart their stepping from the active baseline rather than falling back to the unchosen ramp. The three values differ only in what drives the state: `true` by the prop (via `data-panel-active`), `"checked"` by a wrapped native input (`:has(input:checked)` — zero JS, nothing to re-render), and `"current"` by an existing `aria-current`.
the panel's `emphasis`How loud the `active` state is. Defaults to the panel's own `emphasis`, so the chosen state lands on the same ramp as the resting look — this prop is the override for when the active item should be louder (or quieter) than the panel itself. `"primary"` fills with the feedback accent ramp and flips text to the contrast scale (~150 RGB units of separation — the option that reads at a glance in a dense list). `"secondary"` tints the feedback surface ramp and `"tertiary"` the neutral ramp; both promote text to `bold` and lift the border to the default line, but move the surface only ~7–18 units.
Bindable active index.
"start"Scroll-snap alignment applied to each slide.
"div"HTML element to render. Curated to container-like tags so semantic intent stays clear (no html/script/style/etc).
falseAuto-advance interval in milliseconds. `false`/`0` disables autoplay. Pauses on hover, focus-within, tab-hidden, and prefers-reduced-motion: reduce.
Surface background from the Panel scale (`surface.peak` → `surface.ground`, or `transparent`).
Border style: `none`, `default`, `bold`, or `muted`.
Border color while the surface is `active` — the explicit form of the ring `activeEmphasis` would otherwise pick. It holds through hover, so a `borderInteract` color can't steal it. `inherit` keeps whatever `activeEmphasis` chose; `none` clears it.
Border style applied on hover / focus interaction.
The slides — CarouselItem children (alternative to `items`).
falseRender circular Prev/Next buttons over the viewport.
Bindable reference to the carousel's root element. Typed as `HTMLElement` because the root is a Panel and `as` can change its tag.
Visual emphasis / prominence level.
Semantic color tone: `neutral`, `primary`, `success`, `warning`, `danger`, or `inherit`.
falseRender a tablist of dots that reflect the active slide and jump to a slide when clicked.
falseWhen true, slides scrolled off-screen are marked `aria-hidden` + `inert` so assistive tech and the tab order skip them (APG carousel pattern). Off by default because `inert` changes focus reachability.
Enable interactive (hover / focus / press) affordances and states.
Data-driven array of slides. Each item needs a unique `value`; optional `label` and `align` override the root defaults per item. Body content is provided via named slots (Astro) or named snippets (Svelte) keyed by `value`. Mutually exclusive with slot composition.
Accessible label for the carousel region. Forwarded to `aria-label` on the viewport, and used to announce the active slide via a polite live region.
falseWhen true, next/prev wrap past the ends. Controls the button-disabled state and autoplay wrap behavior.
"horizontal"Axis of scrolling. Horizontal flips the scroll-snap axis to x; vertical flips it to y and bounds the viewport height.
Inner padding from the spacing scale.
Per-slot style and HTML-attribute override bag for the carousel's slots.
Corner radius override from the spacing scale (or `full` for fully rounded).
Corner radius for the bottom-left and bottom-right corners.
Corner radius for the top-left and bottom-left corners.
Corner radius for the top-right and bottom-right corners.
Corner radius for the top-left and top-right corners.
falseSerialize the panel's resolved surface inputs into a `data-panel-config` attribute on the root, enabling runtime surface changes via `updatePanelElement()` (and, for inheritors like Card, `updateCard()`) without a framework runtime — the primary consumer is Astro / vanilla JS, where props don't re-render. Reactive frameworks (Svelte/React/Vue) don't need this: their prop changes re-run the connect already.
Keep (or drop) the always-1px transparent border the panel reserves so a border appearing or changing never shifts layout. Computed automatically — it's reserved when a resting `border` is visible, or when `interactive` / `borderInteract` / `active` can change the border at runtime. Set `false` only on a panel that must not occupy that 1px (e.g. a borderless housing frame that would otherwise add 2px around a set).
Elevation shadow applied to the surface.
"md"Scale for the gap between slides and the size of indicator dots. Cascades to items via CSS custom properties.
1Number of slides visible in the viewport. `"auto"` lets each slide size itself.
Programmatic snippet map for items-array mode. Keys are item `value`s. Alternatively, declare inline snippets whose names match item values as children of <Carousel>.
Apply a frosted-glass effect with backdrop blur over a surface background.
Plus all standard <div> HTML
attributes.
Per-item snap alignment override for this slide.
Optional label for accessibility and dot indicators (e.g. "Slide 1").
Per-slot style and HTML-attribute override bag for the item slot.
Unique identifier for the slide, used to key named slots/snippets and to track the active slide.