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.
A strip of items slides sideways while the page scrolls down and the section stays pinned. Zero JavaScript: a sticky viewport and a translate bound to the section's view timeline. Where scroll timelines are missing it is a normal horizontally scrollable strip.
scroll inside
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 5.2 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 115 · not Firefox · Safari 26; needs scroll-timeline. Elsewhere: a horizontally scrollable strip without pinning (Firefox).
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 HorizontalScroll from '@moonarc/core/HorizontalScroll';
---
<HorizontalScroll length={3} gap="2rem" label="Case studies">
<article class="w-[70vw]">One</article>
<article class="w-[70vw]">Two</article>
<article class="w-[70vw]">Three</article>
</HorizontalScroll>Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/horizontal-scroll.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.
---
/**
* HorizontalScroll — a strip that moves sideways as the page scrolls down.
*
* The section is tall; inside it a sticky viewport holds a max-content
* track whose translate is bound to the section's view timeline, so the
* track crosses exactly while the section is pinned. Zero JS. Browsers
* without scroll timelines get a normal horizontally scrollable strip — a tab
* stop, so the arrow keys scroll it (reduced motion gets the same strip).
* Keyboard focus inside the pinned strip turns it into that strip for as long
* as it stays, so a focused item is scrolled into view by the browser.
*/
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'section'> {
/** Scroll distance as viewport heights: how long the strip stays pinned. */
length?: number;
/** Gap between items, any CSS length. */
gap?: string;
/** Height of the pinned viewport; 100vh for the page, or the height of an enclosing scroller. */
viewport?: string;
/** Accessible name of the strip, which is a tab stop: say what it holds ("Case studies"). */
label?: string;
}
const { length = 3, gap = '2rem', viewport = '100vh', label = 'Horizontal strip', class: className, style, ...rest } = Astro.props;
const vars = [`--ma-hs-len:${length}`, `--ma-hs-gap:${gap}`, `--ma-hs-vp:${viewport}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<section class:list={['ma-hscroll', className]} style={vars} {...rest}>
<div class="ma-hscroll__viewport" tabindex="0" role="region" aria-label={label}>
<div class="ma-hscroll__track">
<slot />
</div>
</div>
</section>
<style is:global>
@layer components {
:where(.ma-hscroll__viewport) {
overflow-x: auto;
container-type: inline-size;
}
:where(.ma-hscroll__track) {
display: flex;
align-items: center;
gap: var(--ma-hs-gap, 2rem);
width: max-content;
height: 100%;
}
@supports (animation-timeline: view()) {
:where(.ma-hscroll) {
height: calc(var(--ma-hs-vp, 100vh) * var(--ma-hs-len, 3));
view-timeline-name: --ma-hs;
}
/* clip, not hidden: a hidden box is still a scroll container, so a focused item scrolled it sideways and the offset
stayed on top of the translate after focus left */
:where(.ma-hscroll__viewport) {
position: sticky;
top: 0;
height: var(--ma-hs-vp, 100vh);
overflow: clip;
}
:where(.ma-hscroll__track) {
will-change: translate;
animation: ma-hscroll linear both;
animation-range: contain 0% contain 100%;
}
:where(html) :where(.ma-hscroll__track) {
animation-timeline: --ma-hs;
}
/* right to left the track starts at the right edge, so it travels right: the same distance, mirrored. [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-hscroll, .ma-hscroll[dir='rtl']) {
--ma-hs-dir: -1;
}
:where([dir='rtl'] [dir='ltr'] .ma-hscroll, [dir='rtl'] .ma-hscroll[dir='ltr']) {
--ma-hs-dir: 1;
}
/* keyboard focus in the strip (the strip itself, or an item) makes it the scrollable strip until focus leaves: the
browser scrolls the focused item into view, and the offset goes when clip comes back. A click on a link or a
button does not (a text field matches :focus-visible however it is focused) */
:where(.ma-hscroll__viewport:is(:focus-visible, :has(:focus-visible))) {
overflow-x: auto;
}
:where(.ma-hscroll__viewport:is(:focus-visible, :has(:focus-visible)) .ma-hscroll__track) {
animation: none;
}
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-hscroll) {
height: auto;
}
:where(.ma-hscroll__viewport) {
position: static;
height: auto;
overflow-x: auto;
}
:where(.ma-hscroll__track) {
animation: none;
}
}
}
/* min(): a track narrower than the viewport stays put instead of travelling the wrong way */
@keyframes ma-hscroll {
to {
translate: calc(var(--ma-hs-dir, 1) * min(0px, 100cqw - 100%)) 0;
}
}
</style>| Prop | Type | Default | Description |
|---|---|---|---|
length | number | 3 | How many viewport heights the section stays pinned for. Roughly one per item. |
gap | string | '2rem' | Gap between items. |
viewport | string | '100vh' | Height of the pinned viewport: the page, or an enclosing scroller's height. |
label | string | 'Horizontal strip' | Accessible name of the strip, which is a tab stop and a region. Say what it holds ("Case studies"). |
Not pinned; the strip scrolls horizontally by hand.
ClientRouterCSS-only; nothing to rebind.
Replaces: GSAP ScrollTrigger pin + horizontal tween · Locomotive horizontal sections. 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.
An element drifts at its own speed as it crosses the viewport, exactly in step with the scroll.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown