The scrollport has two edges
Everything this component does happens in the two thin bands where content meets the boundary of its own scroller.
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.
npm i @magic-spells/scroll-stack
~2.7 kB gzip · zero dependencies
Each card pins at the top edge and fades while the next slides over it.
effect="stack-fade"
snap
Everything this component does happens in the two thin bands where content meets the boundary of its own scroller.
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.
Positions come from offsetTop and offsetHeight, measured
once per invalidation. The per-frame path reads no layout at all.
A passive scroll listener schedules a single coalesced frame. At rest the component schedules nothing — literally zero frames.
<scroll-stack-item> declares the timeline and owns layout; its
first element child takes the pin and the visual effect. Neither fights the other.
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.
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
The component writes scroll-padding equal to the fade zone, so
tabbing into a link never parks it in the invisible band.
Progress is quantized to a thousandth. If nothing moved enough to matter, no style is written and no attribute is toggled.
Spacing between items is --scroll-stack-gap, set here to
1rem. Attributes are for behavior; custom properties are for theming.
One ResizeObserver watches the scroller and every item; a MutationObserver watches the child list. Both funnel into the same single frame.
The last card sits against the bottom edge at full strength, because there is no more scroll behind it to fade into.
<scroll-stack style="--scroll-stack-gap: 1rem">
<scroll-stack-item>
<article class="card">…</article> <!-- the effect lands here -->
</scroll-stack-item>
…
</scroll-stack>
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…
Native scroll snap — mandatory by default, proximity as the opt-out. Switch
the knobs and fling the row.
effect just selects a block of CSS — none leaves you a pure
progress broadcaster.
effect="stack-fade"
snap
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" plus a few lines of your own CSS — here: blur, hue rotation and
a directional slide.
effect="none"
easing="ease-in"
snap
/* 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)));
}
Every attribute, live.
Two hundred rows, no layout reads per frame, zero frames at rest.
snap
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…