A natively scrolling list web component whose items animate at the scrollport edges instead of clipping. The exiting item stops dead at the boundary, fades and shrinks in place, and the next item slides over it; entering items animate in from the far edge. Vertical or horizontal, with mandatory scroll snapping and responsive per-view item sizing, and every effect is plain CSS driven by one custom property. 3.5 kB JS gzip, 0.9 kB CSS gzip, zero dependencies, no Shadow DOM.
No scroll hijacking: the browser scrolls, the component only reads.
npm install @magic-spells/scroll-stackimport '@magic-spells/scroll-stack';
import '@magic-spells/scroll-stack/css';Or via CDN:
<link
rel="stylesheet"
href="https://unpkg.com/@magic-spells/scroll-stack/dist/scroll-stack.min.css" />
<script src="https://unpkg.com/@magic-spells/scroll-stack"></script><scroll-stack style="height: 26rem">
<scroll-stack-item>
<article class="card">First</article>
</scroll-stack-item>
<scroll-stack-item>
<article class="card">Second</article>
</scroll-stack-item>
</scroll-stack>Three rules to remember:
- Give
<scroll-stack>a height (or a width in horizontal mode). It is the scroller; without a bounded size there is nothing to scroll. - Every
<scroll-stack-item>needs one element child. The item is the layout and pinning box; its first element child receives the visual effect. An item with no element child is skipped with a console warning. - Don't put scroll-axis margins on that child. The pin travels in percentages of the content box, so the content is stretched to fill the item box and its margins along the scroll axis are zeroed for you (cross-axis margins — say
margin-inline: autoin a vertical list — are left alone). Use--scroll-stack-gapfor spacing between items.
<scroll-stack direction="horizontal">
<scroll-stack-item><div class="tile">…</div></scroll-stack-item>
</scroll-stack>The progress math is direction-agnostic — the same attributes, vars and effects apply on either axis.
Each item gets a single number: how far it has overshot the nearest scrollport edge, from 0 (fully in view) to 1 (fully faded). JavaScript computes it in one coalesced requestAnimationFrame from offsets cached per invalidation — no layout reads in the per-frame path, and no frames at all while the list is at rest — then writes it as --scroll-stack-progress. Everything visual is a CSS calc() off that value.
Items resting against an edge the list cannot scroll past never animate: the first item is at full strength at scroll 0, the last one at the bottom.
| Attribute | Values | Default | Notes |
|---|---|---|---|
direction |
vertical | horizontal |
vertical |
Sets the scroll axis |
effect |
stack-fade | fade-scale | fade | scale | none |
stack-fade |
Selects a block of CSS; none publishes the vars and nothing else |
pin |
start | end | both | none |
see below | Which edges hold an item still while its flow position moves on |
zone |
item | <n>px | <n>% |
item |
Depth of the animation band; capped at 45% of the scrollport |
edges |
both | start | end |
both |
Which edges animate at all |
snap |
boolean | start | center | proximity |
off | Scroll snapping; presence alone means mandatory start |
per-view |
positive integer | auto |
unset | How many items fill the scrollport |
item-min |
CSS length (px, rem, %) |
unset | Floor for the resolved item length |
item-max |
CSS length (px, rem, %) |
unset | Ceiling for the resolved item length |
easing |
linear | ease-in | ease-out | ease-in-out |
linear |
Applied to progress before it is written |
reduced-motion |
auto | ignore |
auto |
ignore opts out of the reduced-motion override |
disabled |
boolean | off | Removes every var and state attribute, detaches all listeners |
zone="item" uses each item's own length as its band, so tall items fade over a longer stretch than short ones. A fixed zone ("160px", "25%") gives every item the same band.
A pinned item stops moving when it reaches an edge and fades in place while the next one covers it. An unpinned one keeps scrolling and animates as it goes.
Pinning is a CSS scroll-driven animation, not position: sticky. The item declares a view-timeline and stays an ordinary flow box; its content counter-translates along that timeline by exactly the distance the item travels while crossing the edge, so the content appears to hold still while the box it lives in scrolls on past. JavaScript never writes a position, and never touches the pin at all — it is pure CSS, keyframed in percentages so the whole thing runs on the compositor.
Sticky was the obvious way to do this and it cost two things that the animation gets back: the item is a clean snap target again (so snapping can be mandatory — see below), and offsetTop no longer lies during a mid-scroll re-measure.
pin is independent of effect, and an explicit value always wins. With no pin attribute the effect name implies one, which is what keeps the old behavior intact:
| Effect | Implied pin |
|---|---|
stack-fade (or no effect at all) |
start |
| anything else | none |
stack-fade and fade-scale are the same two CSS declarations — the only difference between them is that implied pin. <scroll-stack effect="fade-scale" pin="start"> and <scroll-stack effect="stack-fade"> are the same list.
edges="end" suppresses the implied start pin, since it removes the start edge entirely; an explicit pin is honored either way.
pin="both" pins each item at both edges at once — a deck, where the next item waits held at the end edge and the spent one holds at the start edge. It is the one pin that cannot be two animations (both would drive translate, and the later one in the list would win outright), so it runs a single animation whose keyframes are offset by named timeline ranges: entry for the end edge, exit for the start.
By default items keep their natural size. per-view, item-min and item-max turn the list into a slot-based one, on either axis: the resolved slot is published as --scroll-stack-item-length on the root and drives every item's flex-basis.
<!-- exactly three per view, never narrower than 12rem -->
<scroll-stack direction="horizontal" per-view="3" item-min="12rem"> … </scroll-stack>
<!-- as many as fit while each stays at least 300px, capped at 480px -->
<scroll-stack direction="horizontal" per-view="auto" item-min="300px" item-max="480px">
…
</scroll-stack>The available length is the scrollport minus the scroller's own padding; gaps come from the computed row-gap/column-gap.
per-view="<n>"—slot = (available − (n−1) × gap) / n, then clamped toitem-min/item-maxif set.per-view="auto"(also implied when onlyitem-minis set) — the most whole items that each still clearitem-min, with the remainder divided evenly among them; ifitem-maxis smaller than that slot it wins and the row simply stops filling the port exactly.
Resizing re-runs the whole thing, so a responsive item count comes free.
snap turns on native CSS scroll snapping: scroll-snap-type on the scroller, scroll-snap-align on each wrapper. Presence alone snaps item starts to the edge, snap="center" centers them instead, and both are mandatory. snap="proximity" is the softer opt-out, for lists where a fling should be allowed to overshoot and stay there.
<scroll-stack snap>…</scroll-stack>
<scroll-stack direction="horizontal" snap="center">…</scroll-stack>
<scroll-stack snap="proximity">…</scroll-stack>Mandatory is only safe because items are plain flow boxes. While the pin was position: sticky the wrapper was both the snap target and the stuck box, so a stuck item's snap area tracked the scroll offset rather than its flow position; mandatory read that as "already snapped" mid-fling, truncated the momentum and yanked back an item. Nothing is sticky now, so the snap areas stay put.
A snapping list drops the zone-based scroll-padding the component otherwise writes. Scroll padding shifts every snap position by the same amount, which would leave snapped items short of the edge they were meant to align to — and it is not needed, because a snapped resting position is fully visible by construction, which is the exact guarantee the padding provides for focus scrolling.
On a browser without scroll-driven animations the sticky pin comes back, and so does that bug — so the fallback forces proximity even where you asked for mandatory. See Browser support.
No authored attributes. Three are written by the parent and can be styled against:
[transitioning]— progress is strictly between 0 and 1 (scopedwill-change, so a long list never promotes hundreds of layers)[fully-hidden]— progress reached 1; the item becomespointer-events: none[pinning]— the item currently overlaps an edge it pins at, so its content is displaced by the pin. This is what drives paint order (a pinning item is demoted below its in-view siblings), and it is deliberately wider than the effect zone: a narrowzoneends the fade long before the pin lets go.
Items are never made inert or aria-hidden: a faded item is one scroll away, stays findable with Ctrl+F, and stays reachable by keyboard.
const stack = document.querySelector('scroll-stack');
stack.progress; // Map<HTMLElement, number> — current eased progress per item
stack.direction; // 'vertical' | 'horizontal'
stack.effect; // active effect name
stack.edges; // 'both' | 'start' | 'end'
stack.pin; // resolved pin: 'start' | 'end' | 'both' | 'none'
stack.snap; // 'start' | 'center' | 'proximity' | null when off
stack.itemLength; // resolved slot in px, or null when the list is unsized
stack.disabled = true; // getter/setter, mirrors the attribute
stack.refresh(); // re-measure and force one full passpin reports the resolved value, so it tells you what the list is actually doing even when no pin attribute is present.
refresh() is only needed for changes the observers cannot see — a font swap, a late image decode. Scrolling, resizing the scroller or an item, and adding or removing items are all handled automatically.
Both bubble from the <scroll-stack> element, and fire on state change only — never per frame.
| Event | Detail | Fired when |
|---|---|---|
scroll-stack:item-hidden |
{ item, index, edge } |
progress reaches 1 |
scroll-stack:item-shown |
{ item, index, edge } |
progress leaves 1 |
edge is -1 at the start (top/left) edge and 1 at the end (bottom/right) edge.
stack.addEventListener('scroll-stack:item-hidden', (event) => {
console.log(event.detail.index, 'faded out at the', event.detail.edge === -1 ? 'top' : 'bottom');
});| Property | Default | Effect |
|---|---|---|
--scroll-stack-gap |
0 |
Space between items |
--scroll-stack-shrink |
0.15 |
How far built-in scale effects shrink at full progress |
scroll-stack {
--scroll-stack-gap: 1rem;
--scroll-stack-shrink: 0.25;
}Written on each <scroll-stack-item> by the component:
| Property | Value |
|---|---|
--scroll-stack-progress |
eased progress, 0 → 1, quantized to 1/1000 |
--scroll-stack-edge |
-1 at the start edge, 1 at the end, 0 anywhere in between |
--scroll-stack-edge is published for your effects to key off; nothing inside the component reads it any more (paint order moved to the state attributes).
And on the root, only when the list is sized by per-view/item-min/item-max:
| Property | Value |
|---|---|
--scroll-stack-item-length |
resolved slot length in px, feeds flex-basis |
Set effect="none" and write CSS against the two vars. There is no plugin API — there is nothing a plugin API could do that calc() cannot.
scroll-stack[effect='none'] scroll-stack-item > * {
opacity: calc(1 - var(--scroll-stack-progress, 0));
filter: blur(calc(var(--scroll-stack-progress, 0) * 6px));
transform: translateX(
calc(var(--scroll-stack-edge, 0) * var(--scroll-stack-progress, 0) * -2rem)
);
}The , 0 fallback matters: it is what the item looks like before the component has written anything.
Blur is deliberately not a built-in — it clips at the element's own bounds, so the right amount of padding is an author decision. The wrapper/content split makes author-side blur safe.
Under prefers-reduced-motion: reduce, transforms are dropped and fades are kept — a cross-fade is not motion. It is a pure CSS media query, so it flips instantly. reduced-motion="ignore" opts a list out.
The pin survives reduced motion, deliberately. Counter-translating a pinned item is what stops it moving, so disabling it would add motion rather than remove it. That works out for free: the pin animates translate while the override only touches transform.
The pin needs CSS scroll-driven animations (animation-timeline: view()) — Chrome and Edge 115+, Safari 26+. Everything else in the component is ordinary CSS and works everywhere.
Where scroll-driven animations are missing (Firefox at time of writing), a @supports not block reinstates the original position: sticky pin. The visual result is very close; the difference is snapping. A sticky item is both the snap target and the stuck box, so its snap area tracks the scroll offset and mandatory snapping truncates flings — so the fallback also forces scroll-snap-type down to proximity, even on a list that asked for mandatory. Worse than mandatory, much better than a broken fling.
There is no JavaScript branch for any of this: both paths are @supports blocks in one stylesheet.
- Items are never hidden from assistive technology or find-in-page.
- The component writes
scroll-padding-block/scroll-padding-inlineequal to the resolved zone, so focus-driven scrolling lands focused elements past the fade band instead of inside it. Asnaplist gets no padding and relies on snap positions being fully visible instead. - The list is a real scroll container, so it keeps native keyboard scrolling, scroll-into-view, and momentum.
- Put borders on a wrapper, not on
<scroll-stack>. A border is handled (offsets are corrected byclientTop/clientLeft), but padding on the scroller is the simpler way to inset the content. - Don't put a positioned wrapper between
<scroll-stack>and its items. The scroller must be each item'soffsetParent; if it isn't, the component warns once and falls back to slower rect measurement. - The default effect pins. If you want the same visuals without it, use
pin="none"— oreffect="fade-scale", which implies it. per-viewoverrides your own item widths. The resolved slot lands onflex-basis, which beats awidthon the item — and the content is stretched to fill whatever the slot resolves to. Useitem-min/item-maxto steer the slot, or drop the sizing attributes.pin="both"on items taller than the scrollport is the one oversized case that stays unsupported: its two crossing ranges overlap, so neither edge's keyframes can be expressed monotonically. Single-edge pins (start,end, and the implied default) handle oversized items correctly. Shorten the item or pin one edge.- The pin animates
translate, the effects animatetransform. They compose only because those are separate properties. An author effect that writestranslateon the content element will fight the pin — usetransformfor your own motion. - Don't rewrite the pin keyframes with
var()orcalc(). A transform-family keyframe that references a custom property can't be composited, so Chrome drops the animation to the main thread — one frame behind the scroll, which at fling speed is visible lag. The percentages are load-bearing. - A snapping list ignores
zoneforscroll-paddingpurposes only. The zone still drives the animation exactly as before.
MIT
Made by Cory Schulz