# Horizontal Sticky Stack - Skill Reference > A tall section pins a row of cards to the viewport; as the page scrolls, each card slides left and stacks on the previous one, driven only by a CSS scroll-driven animation (`animation-timeline` on a named `view-timeline`). ## What this teaches - `position: sticky` on a wrapper inside a tall section (height = card height x card count) to pin the stage while the page scrolls - `view-timeline-name` on the section, consumed by `animation-timeline: --hss` on each card, so scroll position drives the animation with no JS - `animation-range: exit-crossing 0% exit-crossing N%` to give each card its own slice of the scroll, with the range end computed per card from `--index` and `--total` - Per-card CSS custom properties (`--index`, `--total`) plus `calc()` for start offset, slide distance and range, so one `@keyframes` serves every card - Separate wrappers for sticky, sizing and `transform`, so the animated transform never fights the layout - A mobile variant of the same idea: stacked `position: sticky` cards whose inner card scales down as the next one covers it - A `CSS.supports("animation-timeline", "scroll()")` guard that shows a notice instead of a broken layout ## Core concept Scroll distance is the clock. The section is `--total` card-heights tall, so scrolling through it takes a predictable distance, and the sticky child keeps the stage in view the whole time. Every card starts parked to the right at `index * (width + gap)`; the keyframe only has one job, translating to `index * spineWidth`, so card N ends N spine-widths from the left edge and the earlier cards peek out as labelled strips. Card 0 never moves, and card N finishes at `70% / total * N` of the exit range, so later cards travel further, longer, and arrive in order. ## Use it ### Embed (no build step) ```html

First card

Short description.

Second card

Short description.

``` - Every key of `defaults` (see the table below) is a kebab-case `data-*` attribute on the container: `cardHeight` -> `data-card-height`. Numbers are parsed with `Number`, strings are used as is. - The `items` option and `scroll` are JS-only: they are not keys of `defaults`, so the embed cannot read them from attributes. With the embed, cards always come from the markup (as above). - The script exports `init(root)` and `get(el)`; it auto-starts on `DOMContentLoaded`. Call `HorizontalStickyStack.init(root)` for elements added later. The global is `HorizontalStickyStack`. - Needs a browser with CSS scroll-driven animations (Chromium, Safari 26+). Elsewhere the effect shows a notice at the top of the stack. ### Bundled apps ```ts import { mount, defaults } from "@vanilla-motion/horizontal-sticky-stack"; const container = document.querySelector("#stack")!; const fx = mount(container, { // Cards: when omitted, they are read from [data-horizontal-sticky-stack-item] children of container. items: [ { title: "First card", text: "Short description.", image: "/img/one.jpg", color: "#FFD6E0", label: "Step" }, { title: "Second card", text: "Short description." }, // image, color and label are optional ], // scroll: true only when the container has a fixed height and must be the scroller itself // (e.g. a canvas); leave it off on a normal page, where the page scrolls. cardHeight: 450, }); fx.setParams({ cornerRadius: 16, layout: "stack" }); // partial update, written as CSS variables fx.dispose(); // restores the container's original HTML and overflow-y ``` `mount(container, options)` replaces the container's content and returns `HorizontalStickyStackHandle`: ```ts interface HorizontalStickyStackHandle { setParams(next: Partial): void; dispose(): void; } ``` `options` = any `HorizontalStickyStackParams` key, plus `items?: StackItem[]` and `scroll?: boolean`. `setParams` cannot change the cards or `scroll`; `dispose()` and `mount` again for that. Also exported: `defaults`, the `HorizontalStickyStack` class (`static readonly version`). ### Params All lengths are CSS pixels. Breakpoints (768px, 1024px) are fixed in the CSS. Ranges are the Toolcraft panel's. | Key | Default | Range / options | Meaning | | --- | --- | --- | --- | | `layout` | `"stack"` | `"stack"`, `"static"` | `stack`: pinned scroll-driven stack. `static`: same cards as a plain vertical list, no pinning, no animation | | `containerWidth` | `1200` | 600 to 1600, step 10 | Width of the centred column; card width from 1024px | | `cardHeight` | `450` | 240 to 700, step 5 | Card height from 1024px | | `cardHeightSmall` | `360` | 240 to 700, step 5 | Card height from 768px to 1023px | | `cardWidthSmall` | `800` | 400 to 1100, step 10 | Card width from 768px to 1023px | | `spineWidth` | `50` | 0 to 120, step 1 | Width of the vertical label strip; also how far each card peeks out once stacked | | `cardGap` | `20` | 0 to 80, step 1 | Space between cards before they slide together | | `cornerRadius` | `32` | 0 to 64, step 1 | Rounding of the cards and their images | | `mobileStackGap` | `10` | 0 to 40, step 1 | Visible step of each card above the next one under 768px | | `mobileCardMinHeight` | `500` | 200 to 800, step 10 | Smallest card height under 768px (otherwise 65% of the view) | | `bgColor` | `"#FFFFFF"` | any CSS color (panel: `#RRGGBB`) | Background behind the cards | | `bgOpacity` | `1` | 0 to 1, step 0.01 | Opacity of `bgColor`; 0 lets the host page show through | | `textColor` | `"#18181B"` | any CSS color | Card titles | | `mutedColor` | `"#6A7282"` | any CSS color | Card text and label strip | | `mediaColor` | `"#E2E8F0"` | any CSS color | Backdrop behind an image while it loads | ## Key code ### HTML structure ```html

01Label

Title

Text

``` ### CSS - the effect ```css /* Name the section's view timeline so the cards (its descendants) can subscribe to it */ .hss { view-timeline-name: --hss; } @media (min-width: 768px) { /* section is --total card-heights tall: that scroll distance is the animation's runway */ .hss { height: calc(var(--h) * var(--total)); } /* sticky pins the stage while the tall section scrolls past */ .hss-sticky { position: sticky; top: 0; overflow: hidden; display: flex; align-items: center; } .hss-item { /* card N finishes at 70% / total * N of the range: later cards move longer, 70% leaves a still tail */ --end-range: calc(70% / var(--total) * var(--index)); /* final position: N spine-widths from the left, so earlier cards peek out as strips */ --to-x: calc(var(--index) * var(--hss-spine)); position: absolute; top: 0; left: 0; height: var(--h); width: var(--w); /* start parked to the right, one card width + gap per index */ transform: translateX(calc(100% * var(--index) + var(--hss-gap) * var(--index))); /* linear: the scroll position is the easing; forwards + both keeps the end state after the range */ animation: linear hss-slide forwards; animation-timeline: --hss; animation-range: exit-crossing 0% exit-crossing var(--end-range); animation-fill-mode: both; } } /* One keyframe for every card: only the destination, the start is the transform above */ @keyframes hss-slide { to { transform: translateX(var(--to-x)); } } /* Mobile: no horizontal slide. Cards are sticky and the covered ones scale down. */ @media (max-width: 767.98px) { .hss-item { position: sticky; top: 0; /* each later card sticks a little lower, so a strip of the previous card stays visible */ padding-top: calc((var(--index) + 1) * var(--hss-stack-gap)); margin-bottom: calc((var(--index) + 1) * var(--hss-stack-gap) * -1); } .hss-card { /* each card owns one equal slice of the range, in order */ --start-range: calc(100% / var(--total) * var(--index)); --end-range: calc(100% / var(--total) * (var(--index) + 1)); transform-origin: top; animation: ease-in hss-scale forwards; animation-timeline: --hss; animation-range: exit-crossing var(--start-range) exit-crossing var(--end-range); } } /* --hss-reverse = total - index: the earlier the card, the smaller it ends (last card stays 1) */ .hss-item { --hss-reverse: calc(var(--total) - var(--index)); } @keyframes hss-scale { to { transform: scale(calc(1.1 - calc(.1 * var(--hss-reverse)))); } } /* Static layout: switch everything off, keep the cards */ .hss.hss-static .hss-sticky { position: static; } .hss.hss-static .hss-item { position: static; transform: none; animation: none; } ``` ### JS - setup only (no scroll logic) ```ts // The motion is pure CSS. JS builds the cards and writes params as custom properties. root.style.setProperty("--total", String(cards.length)); // drives section height and every range item.style.setProperty("--index", String(index)); // drives start offset, slide distance, range // Browsers without scroll-driven animations get a notice instead of a stuck layout if (!CSS.supports("animation-timeline", "scroll()")) { root.prepend(notice); } // params -> custom properties; changing one restyles instantly, nothing runs per frame root.style.setProperty("--hss-h", `${params.cardHeight}px`); root.classList.toggle("hss-static", params.layout === "static"); // Only when the container itself must scroll (fixed-height host, `scroll: true`): // the slide finishes while the section leaves the top, so add one view of empty space after it container.style.overflowY = "auto"; tail.style.height = `${container.clientHeight}px`; root.style.setProperty("--hss-view", `${container.clientHeight}px`); // replaces 100vh for sticky min-height ``` ## Variants ### Horizontal Sticky Stack (default) Four cards pinned and sliding together as the host scrolls (`layout: "stack"`, with `scroll: true` so the demo canvas is the scroller). Scroll-driven `translateX` on desktop, sticky scale stack under 768px. ### The first stack (first-stack) One card only, `layout: "static"`: the box model everything else builds on (spine, media column, copy) before any pinning or motion. ### Static, responsive layout (static-layout) The same four cards with `layout: "static"`: a plain responsive vertical list. `.hss-static` resets `position`, `transform` and `animation`, so this is the layout the animation is added on top of. Keep the wrappers separate (sticky, sizing, transform) so the animation steps do not conflict. ## Adapt to your layout - **Card count**: add or remove `.hss-item` children and update `--total`; the section height and every `animation-range` follow from it. - **Peek amount**: `--hss-spine` (`spineWidth`) is both the label strip width and the final offset per card. Set it to 0 and the cards stack fully on top of each other. - **Slide timing**: `70%` in `--end-range` is the share of the exit range the last card needs. Raise it for a slower finish, lower it to finish earlier. - **Section length**: scroll distance is `--h * --total`. Change `cardHeight` or the `height` calc to make the scrub longer or shorter. - **Breakpoints**: 768px and 1024px are hard-coded in the injected CSS; copy the stylesheet and edit them to change. - **Class names**: `hss-*` are arbitrary; keep the four wrapper roles (sticky, stack, item, card). ## Browser support CSS scroll-driven animations (`animation-timeline`, `animation-range`, `view-timeline-name`): Chromium 115+, Safari 26+; Firefox only behind a flag at the time of writing. Feature-detect with `CSS.supports("animation-timeline", "scroll()")`. `position: sticky` works in all modern browsers. If you need Firefox, use `layout: "static"` as the fallback. ## Accessibility ```ts // The stack is motion tied to scroll. For reduced motion, use the static list: no pinning, no animation. const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; mount(container, { layout: reduce ? "static" : "stack" }); ``` Don't shorten the motion, remove it: the static layout shows the same content in reading order. Card images get `alt=""` (decorative), so keep the meaning in the card title and text.