@magic-spells/scroll-stack

Scrolls in, animates out

A list that scrolls natively — no wheel hijacking, no fake scrollbar — but whose items stop clipping at the edges and animate out instead. The exiting item stops dead at the boundary, fades in place, and the next one slides over it.

Install npm i @magic-spells/scroll-stack ~2.7 kB gzip · zero dependencies
effect · stack-fade

The default handoff

Each card pins at the top edge and fades while the next slides over it.

effect="stack-fade" snap
01

The scrollport has two edges

Everything this component does happens in the two thin bands where content meets the boundary of its own scroller.

02

Progress, not position

Each item gets one number: how far it has overshot the nearest edge, from zero to one. The list writes it as a custom property and stops there.

03

Offsets, cached

Positions come from offsetTop and offsetHeight, measured once per invalidation. The per-frame path reads no layout at all.

04

One frame per burst

A passive scroll listener schedules a single coalesced frame. At rest the component schedules nothing — literally zero frames.

05

The wrapper pins, the content moves

<scroll-stack-item> declares the timeline and owns layout; its first element child takes the pin and the visual effect. Neither fights the other.

06

In-view items paint on top

Pinned content is translated, so it would cover its neighbours at either edge. One rule demotes exactly the items inside a zone — no vars, no direction to special-case.

07

Nothing is inert

Faded items keep their text findable and their links reachable. A hidden item is always exactly one scroll away, so hiding it from assistive tech would be a lie.

Tab to me mid-scroll
08

Focus lands past the fade

The component writes scroll-padding equal to the fade zone, so tabbing into a link never parks it in the invisible band.

And me
09

Writes only on change

Progress is quantized to a thousandth. If nothing moved enough to matter, no style is written and no attribute is toggled.

10

The gap is a custom property

Spacing between items is --scroll-stack-gap, set here to 1rem. Attributes are for behavior; custom properties are for theming.

11

Resize keeps up

One ResizeObserver watches the scroller and every item; a MutationObserver watches the child list. Both funnel into the same single frame.

12

Then it just stops

The last card sits against the bottom edge at full strength, because there is no more scroll behind it to fade into.

view the markup
<scroll-stack style="--scroll-stack-gap: 1rem">
  <scroll-stack-item>
    <article class="card">…</article>   <!-- the effect lands here -->
  </scroll-stack-item>
  …
</scroll-stack>
direction · horizontal

Sideways, same math

One attribute swaps the axis, and per-view="auto" decides how many tiles fit — resize the window and watch the count change.

direction="horizontal" per-view="auto" item-min="300px" item-max="480px" snap measuring…
snap · per-view · item-min/max

A snap carousel

Native scroll snap — mandatory by default, proximity as the opt-out. Switch the knobs and fling the row.

effect · five values

Every effect is a stylesheet rule

effect just selects a block of CSS — none leaves you a pure progress broadcaster.

effect="stack-fade" snap
pin · both

Both sides

pin="both" deals the next card from a deck at the bottom and retires the spent one at the top.

effect="fade-scale" pin="both" snap
effect · none + author css

Write your own

effect="none" plus a few lines of your own CSS — here: blur, hue rotation and a directional slide.

effect="none" easing="ease-in" snap
view the css
/* the whole effect */
.ink-list scroll-stack-item > * {
  opacity: calc(1 - .85 * var(--scroll-stack-progress, 0));
  filter:
    blur(calc(var(--scroll-stack-progress, 0) * 7px))
    hue-rotate(calc(var(--scroll-stack-progress, 0) * 120deg))
    saturate(calc(1 + var(--scroll-stack-progress, 0) * 2.5));
  transform:
    translateX(calc(var(--scroll-stack-edge, 0) * var(--scroll-stack-progress, 0) * -2.5rem))
    scale(calc(1 - .06 * var(--scroll-stack-progress, 0)));
}
zone · easing · edges · pin · direction

Turn the knobs

Every attribute, live.

Zone item
200 items

Two hundred, still idle

Two hundred rows, no layout reads per frame, zero frames at rest.

snap
mutation · events

Items come and go

Add and remove items mid-scroll; the log listens for the item-hidden/item-shown events.

snap

waiting for an item to cross an edge…