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-snap
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 IntersectionObserver.
---
import Slider from "../Slider.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>
<Slider
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>
</Slider>
</div>
</div>Use orientation="horizontal" (default) or "vertical" to flip the scroll and snap axis. Vertical sliders 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 Slider from "../Slider.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>
<Slider
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>
</Slider>
</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>
<Slider
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>
</Slider>
</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 Slider from "../Slider.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>
<Slider 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>
</Slider>
</div>
<div>
<h3><code>slidesPerView={2}</code></h3>
<p>Two slides share the viewport; snap keeps pairs aligned.</p>
<Slider 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>
</Slider>
</div>
<div>
<h3><code>slidesPerView={3}</code></h3>
<p>Three per row — the classic product-carousel layout.</p>
<Slider 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>
</Slider>
</div>
<div>
<h3><code>slidesPerView="auto"</code></h3>
<p>
Items size themselves — useful when cards have intrinsic widths. Mix
different widths freely.
</p>
<Slider 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>
</Slider>
</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">
<Slider
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>
</Slider>
</div>
<div style="flex: 1 1 0; min-width: 200px">
<Slider
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>
</Slider>
</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 Slider from "../Slider.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>
<Slider 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>
</Slider>
</div>
<div>
<h3>Medium (default)</h3>
<Slider 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>
</Slider>
</div>
<div>
<h3>Large</h3>
<p>
Size cascades to items through CSS custom properties — children inherit
gap and indicator size without prop drilling.
</p>
<Slider 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>
</Slider>
</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 Slider from "../Slider.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>
<Slider 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>
</Slider>
</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>
<Slider 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>
</Slider>
</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 Slider from "../Slider.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>
<Slider 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>
</Slider>
</div>
<div>
<h3>Indicators + controls</h3>
<p>Combine both for full-featured navigation.</p>
<Slider 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>
</Slider>
</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.
loop
Autoplay advances every 3000 ms. It pauses on hover, focus-within,
tab-hidden, and when the user has
prefers-reduced-motion: reduce — respecting the platform by default.
---
import Slider from "../Slider.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>
<Slider
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>
</Slider>
</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 Slider from "../Slider.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>
<Slider
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
>
</Slider>
</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>
<Slider
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
>
</Slider>
</div>
<div>
<h3><code>align="end"</code></h3>
<p>Snap anchor at the trailing edge.</p>
<Slider
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
>
</Slider>
</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">
<Slider
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>
</Slider>
</div>
<div style="flex: 1 1 0; min-width: 140px">
<Slider
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>
</Slider>
</div>
<div style="flex: 1 1 0; min-width: 140px">
<Slider
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>
</Slider>
</div>
</div>
</div>
</div>Two APIs are supported: a data-driven items array with named slots/snippets keyed by value, and direct <SliderItem> 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's
value). Best for data that comes from a backend, a CMS, or a
static list.
Drop <SliderItem> children directly into the slider for maximum
flexibility — ideal when each slide has bespoke markup or different structure.
When both items and children are provided, items wins.
---
import Slider from "../Slider.astro";
import SliderItem from "../SliderItem.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>
<Slider
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>
</Slider>
</div>
<div>
<h3>Slot composition</h3>
<p>
Drop <code><SliderItem></code> children directly into the slider 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>
<Slider label="Slot API" controls>
<SliderItem value="a"><div class={tile(0)}>First</div></SliderItem>
<SliderItem value="b"><div class={tile(1)}>Second</div></SliderItem>
<SliderItem value="c"><div class={tile(2)}>Third</div></SliderItem>
</Slider>
</div>
</div>The passThrough prop targets any slot (root, viewport, track, item, controls, prevButton, nextButton, indicators, indicator). Each slot accepts a style (Panda SystemStyleObject) and props (HTML attributes).
Use passThrough to target any slot. style
accepts a Panda SystemStyleObject; props
forwards arbitrary HTML attributes — useful for overriding
aria-label, id, or data attributes.
---
import Slider from "../Slider.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>
<Slider
label="Custom-styled carousel"
controls
indicators
items={items}
passThrough={{
root: {
style: { maxWidth: "640px", mx: "auto" },
props: { "data-testid": "showcase-slider" },
},
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>
</Slider>
</div>
</div>| prop | type | default | req | description |
|---|---|---|---|---|
| orientation | "horizontal""vertical" | "horizontal" | Axis of scrolling. Horizontal flips the scroll-snap axis to x; vertical flips it to y and bounds the viewport height. | |
| size | "sm""md""lg" | "md" | Scale for the gap between slides and the size of indicator dots. Cascades to items via CSS custom properties. | |
| slidesPerView | 123"auto" | 1 | Number of slides visible in the viewport. auto lets each slide size itself. | |
| align | "start""center""end" | "start" | Scroll-snap alignment applied to each slide. | |
| feedback | "neutral""primary""success""warning""danger" | undefined | Semantic feedback color forwarded via colorPalette. Controls indicator color and accent surfaces. | |
| radius | "none""5xs""4xs""3xs""2xs""xs""sm""md""lg""xl""2xl""3xl""4xl""5xl""6xl""7xl""8xl""9xl""10xl""11xl""full" | undefined | Border radius applied via connectPanel on the root. | |
| controls | boolean | false | Render circular Prev/Next buttons over the viewport. | |
| indicators | boolean | false | Render a tablist of dots that reflect the active slide and jump to a slide when clicked. | |
| loop | boolean | false | When true, next/prev wrap past the ends. Controls the button-disabled state and autoplay wrap behavior. | |
| autoplay | numberfalse | false | Auto-advance interval in milliseconds. false or 0 disables autoplay. Pauses on hover, focus-within, tab-hidden, and prefers-reduced-motion: reduce. | |
| label | string | undefined | Accessible label for the carousel region. Forwarded to aria-label on the viewport. | |
| items | Array<{ value: string; label?: string; align?: 'start' | 'center' | 'end' }> | undefined | 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) k…
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. Type Default Required | |
| activeIndex | number | 0 | Controlled active index (React/Svelte). In Svelte this is `$bindable`; in React pair with `onActiveIndexChange` for a controlled pattern. | |
| onActiveIndexChange | (index: number) => void | undefined | Called when the active slide changes via scroll, controls, indicators, or autoplay (React only). | |
| passThrough | { [slot]?: { style?: SystemStyleObject; props?: HTMLAttributes<HTMLElement> & Record<string, unknown> } } | undefined | Custom styling and props for slider slots. Accepts root, viewport, track, item, controls, prevButton, nextButton, indicators, and indicator. | |
| ...rest | HTMLAttributes<HTMLDivElement> | - | Standard HTML div attributes forwarded to the root. |