# Card Stack (Moonarc)

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. Zero JavaScript: sticky positioning plus each card's scale bound to the next card's view timeline. Up to eight cards.

- Import: `import CardStack from '@moonarc/core/CardStack'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/card-stack.json`
- Tier A · category scroll · trigger scroll
- Readout: `<CardStack scale={0.92}>`
- Browser support: limited (Chrome 116 · not Firefox · Safari 26); elsewhere: cards still stack with sticky positioning, without the shrink (Firefox, Chrome 115)
- Measured cost: 0 B JS (CSS 5.7 kB raw)
- Page: https://moonarc.dev/components/card-stack/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `top` | `string` | `'10vh'` | Sticky offset from the top of the viewport. |
| `peek` | `string` | `'1rem'` | Extra offset per card so the stacked edges show. |
| `scale` | `number` | `0.92` | Scale a covered card shrinks to. |
| `height` | `string` | `'70vh'` | Minimum height of each card; it is what you scroll through. |

## Usage

```astro
<CardStack top="8vh" peek="1.25rem">
  <section class="rounded-2xl bg-surface p-8">One</section>
  <section class="rounded-2xl bg-surface p-8">Two</section>
  <section class="rounded-2xl bg-surface p-8">Three</section>
</CardStack>
```

## Reduced motion

Cards stack without shrinking or dimming.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- A card cannot read its own stuck position, so each card animates on the next card's timeline: the shrink is driven by the thing covering it, which is what the eye expects.
- timeline-scope on the container hoists eight named timelines so siblings can reference each other.
- transform-origin at the top edge, so a shrinking card recedes under the incoming one instead of floating.
- Give every card an opaque background; sticky stacking only reads when the lower card is hidden.

## Replaces

- ScrollStack (React Bits)
- StickyScroll (Aceternity)
- GSAP pinned card stacks

## Source

```astro
---
/**
 * CardStack — cards stick as you scroll and each one shrinks back as the
 * next slides over it. Zero JS: every child is sticky, and each card's
 * scale is bound to the *next* card's view timeline through timeline-scope,
 * so the shrink is exactly in step with the cover. Up to eight cards.
 * Without scroll timelines, or without timeline-scope (Chrome 115), the
 * cards still stack; they just do not shrink.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Sticky offset from the top of the viewport, any CSS length. */
  top?: string;
  /** Extra offset per card so the stack's edges peek out. */
  peek?: string;
  /** Scale a covered card shrinks to. */
  scale?: number;
  /** Minimum height of each card's slot, so there is something to scroll through. */
  height?: string;
}

const { top = '10vh', peek = '1rem', scale = 0.92, height = '70vh', class: className, style, ...rest } = Astro.props;
const vars = [`--ma-stack-top:${top}`, `--ma-stack-peek:${peek}`, `--ma-stack-scale:${scale}`, `--ma-stack-h:${height}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<div class:list={['ma-stack', className]} style={vars} {...rest}>
  <slot />
</div>

<style is:global>
  @layer components {
    :where(.ma-stack) {
      display: grid;
      gap: var(--ma-stack-peek, 1rem);
      timeline-scope: --ma-c1, --ma-c2, --ma-c3, --ma-c4, --ma-c5, --ma-c6, --ma-c7, --ma-c8;
    }
    :where(.ma-stack > *) {
      position: sticky;
      top: calc(var(--ma-stack-top, 10vh) + var(--ma-stack-i, 0) * var(--ma-stack-peek, 1rem));
      min-height: var(--ma-stack-h, 70vh);
      transform-origin: 50% 0;
    }
    :where(.ma-stack > :nth-child(1)) { --ma-stack-i: 0; view-timeline-name: --ma-c1; }
    :where(.ma-stack > :nth-child(2)) { --ma-stack-i: 1; view-timeline-name: --ma-c2; }
    :where(.ma-stack > :nth-child(3)) { --ma-stack-i: 2; view-timeline-name: --ma-c3; }
    :where(.ma-stack > :nth-child(4)) { --ma-stack-i: 3; view-timeline-name: --ma-c4; }
    :where(.ma-stack > :nth-child(5)) { --ma-stack-i: 4; view-timeline-name: --ma-c5; }
    :where(.ma-stack > :nth-child(6)) { --ma-stack-i: 5; view-timeline-name: --ma-c6; }
    :where(.ma-stack > :nth-child(7)) { --ma-stack-i: 6; view-timeline-name: --ma-c7; }
    :where(.ma-stack > :nth-child(8)) { --ma-stack-i: 7; view-timeline-name: --ma-c8; }
    /* timeline-scope arrived a release after view timelines (Chrome 116): without it the names never reach a sibling */
    @supports (animation-timeline: view()) and (timeline-scope: none) {
      :where(.ma-stack > :not(:last-child)) {
        animation: ma-stack linear both;
        animation-range: entry 0% entry 100%;
      }
      :where(.ma-stack > :nth-child(1):not(:last-child)) { animation-timeline: --ma-c2; }
      :where(.ma-stack > :nth-child(2):not(:last-child)) { animation-timeline: --ma-c3; }
      :where(.ma-stack > :nth-child(3):not(:last-child)) { animation-timeline: --ma-c4; }
      :where(.ma-stack > :nth-child(4):not(:last-child)) { animation-timeline: --ma-c5; }
      :where(.ma-stack > :nth-child(5):not(:last-child)) { animation-timeline: --ma-c6; }
      :where(.ma-stack > :nth-child(6):not(:last-child)) { animation-timeline: --ma-c7; }
      :where(.ma-stack > :nth-child(7):not(:last-child)) { animation-timeline: --ma-c8; }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-stack > *) {
        animation: none;
      }
    }
  }

  @keyframes ma-stack {
    to {
      scale: var(--ma-stack-scale, 0.92);
      filter: brightness(0.85);
    }
  }
</style>

```
