Card Stack
ScrollCards stick to the top as you scroll and each one shrinks and dims as the next slides over it, as on Apple product pages.
Components / UI
A scroll-snap strip with previous/next buttons and dot markers that the browser generates and keeps in sync. Zero JavaScript. Touch, momentum, keyboard and snapping are native everywhere; the buttons and dots appear where ::scroll-button() and ::scroll-marker exist.
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
JavaScript of its own
0 B
CSS 6.6 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 135 · not Firefox · not Safari; needs scroll-snap, scroll-marker. Elsewhere: a swipeable, keyboard-scrollable snap strip without buttons or dots (Firefox, Safari).
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import Carousel from '@moonarc/core/Carousel';
---
<Carousel perView={3} gap="1rem" label="Testimonials">
<figure>…</figure>
<figure>…</figure>
<figure>…</figure>
</Carousel>Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/carousel.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* Carousel — scroll-snap strip with native buttons and markers, zero JS.
*
* Children are the slides. Scrolling, snapping, momentum and touch are the
* browser's. Where ::scroll-button() and ::scroll-marker exist (Chromium),
* previous/next buttons and dots are generated pseudo-elements that scroll
* the container and track the current slide; elsewhere the strip is still
* swipeable and keyboard-scrollable, without the chrome.
*/
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'div'> {
/** Slides visible at once. */
perView?: number;
/** Gap between slides, any CSS length. */
gap?: string;
/** Dot markers under the strip. */
markers?: boolean;
/** Previous / next buttons. */
buttons?: boolean;
/** Snap strictness. */
snap?: 'mandatory' | 'proximity';
/** Accessible name for the strip. */
label?: string;
}
const { perView = 1, gap = '1rem', markers = true, buttons = true, snap = 'mandatory', label = 'Carousel', class: className, style, ...rest } = Astro.props;
const vars = [`--ma-car-n:${perView}`, `--ma-car-gap:${gap}`, `--ma-car-snap:${snap}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<div class:list={['ma-carousel', className]} data-markers={markers ? '' : undefined} data-buttons={buttons ? '' : undefined} style={vars} {...rest}>
<div class="ma-carousel__track" role="region" aria-label={label} tabindex="0">
<slot />
</div>
</div>
<style is:global>
@layer components {
:where(.ma-carousel) {
position: relative;
}
:where(.ma-carousel__track) {
display: grid;
grid-auto-flow: column;
grid-auto-columns: calc((100% - (var(--ma-car-n, 1) - 1) * var(--ma-car-gap, 1rem)) / var(--ma-car-n, 1));
gap: var(--ma-car-gap, 1rem);
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x var(--ma-car-snap, mandatory);
scroll-behavior: smooth;
scrollbar-width: none;
scroll-marker-group: after;
}
:where(.ma-carousel__track)::-webkit-scrollbar {
display: none;
}
:where(.ma-carousel__track:focus-visible) {
outline: 2px solid currentColor;
outline-offset: 4px;
border-radius: 0.5rem;
}
:where(.ma-carousel__track > *) {
scroll-snap-align: start;
scroll-snap-stop: always;
}
/* buttons — generated, and they scroll the container by one page. Plain absolute insets against the
positioned root: the pseudo-elements sit outside the track's scrollable overflow, so they never scroll
with the slides, and no anchor is needed (anchoring a pseudo-element to its own container is not
reliably supported, and the two buttons then land on top of each other). */
:where(.ma-carousel[data-buttons] .ma-carousel__track)::scroll-button(*) {
position: absolute;
top: 50%;
translate: 0 -50%;
z-index: 1;
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
border: 1px solid var(--ma-edge);
background: var(--ma-panel);
color: inherit;
cursor: pointer;
font: inherit;
transition: opacity var(--ma-duration-fast) var(--ma-ease-out), translate var(--ma-duration-fast) var(--ma-ease-out);
}
:where(.ma-carousel[data-buttons] .ma-carousel__track)::scroll-button(*):disabled {
opacity: 0.3;
cursor: default;
}
:where(.ma-carousel[data-buttons] .ma-carousel__track)::scroll-button(*):hover:not(:disabled) {
translate: 0 calc(-50% - 1px);
}
/* with markers the slides sit above a marker row: centre on the slides, not on the whole box */
:where(.ma-carousel[data-buttons][data-markers] .ma-carousel__track)::scroll-button(*) {
top: calc(50% - 0.6875rem);
}
:where(.ma-carousel[data-buttons] .ma-carousel__track)::scroll-button(inline-start) {
content: var(--ma-car-prev, '←') / 'Previous';
inset-inline-start: 0.5rem;
}
:where(.ma-carousel[data-buttons] .ma-carousel__track)::scroll-button(inline-end) {
content: var(--ma-car-next, '→') / 'Next';
inset-inline-end: 0.5rem;
}
/* right to left the inline-start button is the right-hand one, so the arrows turn with it. [dir] and not :dir():
:dir() reached Chrome in 120, and a build that targets an older Chrome rewrites it into a guess from lang, which a page that sets only dir never
matches. An ltr island one level inside an rtl page is set back */
:where([dir='rtl'] .ma-carousel, .ma-carousel[dir='rtl']) {
--ma-car-prev: '→';
--ma-car-next: '←';
}
:where([dir='rtl'] [dir='ltr'] .ma-carousel, [dir='rtl'] .ma-carousel[dir='ltr']) {
--ma-car-prev: '←';
--ma-car-next: '→';
}
/* markers — one per slide, current one filled */
:where(.ma-carousel[data-markers] .ma-carousel__track)::scroll-marker-group {
display: flex;
justify-content: center;
padding-top: 0.875rem;
}
/* a 24 px target (WCAG 2.5.8) around the 8 px dot: the padding is hit area, the colour stays on the content box, the
negative block margin keeps the row as tall as the dot, and the targets sit edge to edge */
:where(.ma-carousel[data-markers] .ma-carousel__track > *)::scroll-marker {
content: '';
width: 0.5rem;
height: 0.5rem;
padding: max(0.5rem, calc(12px - 0.25rem));
margin-block: calc(-1 * max(0.5rem, calc(12px - 0.25rem)));
border-radius: 999px;
background: var(--ma-edge);
background-clip: content-box;
transition: background-color var(--ma-duration-fast) var(--ma-ease-out), scale var(--ma-duration-fast) var(--ma-ease-out);
}
:where(.ma-carousel[data-markers] .ma-carousel__track > *)::scroll-marker:target-current {
background-color: currentColor;
scale: 1.25;
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-carousel__track) {
scroll-behavior: auto;
}
}
}
</style>| Prop | Type | Default | Description |
|---|---|---|---|
perView | number | 1 | Slides visible at once. |
gap | string | '1rem' | Gap between slides. |
markers | boolean | true | Dot markers under the strip (where supported). |
buttons | boolean | true | Previous / next buttons (where supported). |
snap | 'mandatory' | 'proximity' | 'mandatory' | Snap strictness. |
label | string | 'Carousel' | Accessible name of the scrolling region. |
Buttons and markers jump instead of smooth-scrolling; the strip still snaps.
ClientRouterCSS-only; scroll position resets with the page.
Replaces: Embla · Swiper for simple strips · Carousel (shadcn) · Carousel (React Bits). See the migration table.
Cards stick to the top as you scroll and each one shrinks and dims as the next slides over it, as on Apple product pages.
Infinite scrolling strip for logos, quotes or tags in any direction.
npx astro add moonarc
pnpm astro add moonarc
npx shadcn@latest add …/r/tabs.json
Tabs with an indicator that slides to the selected one and panels that fade in.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown