# 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
```
### 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.