Skip to content

Repository files navigation

Scroll Stack

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.

Live Demo

Install

npm install @magic-spells/scroll-stack
import '@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>

Usage

<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:

  1. Give <scroll-stack> a height (or a width in horizontal mode). It is the scroller; without a bounded size there is nothing to scroll.
  2. 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.
  3. 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: auto in a vertical list — are left alone). Use --scroll-stack-gap for spacing between items.

Horizontal

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

How it works

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.

Attributes

<scroll-stack>

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.

Pinning

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.

Sizing

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 to item-min/item-max if set.
  • per-view="auto" (also implied when only item-min is set) — the most whole items that each still clear item-min, with the remainder divided evenly among them; if item-max is 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.

Snapping

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.

<scroll-stack-item>

No authored attributes. Three are written by the parent and can be styled against:

  • [transitioning] — progress is strictly between 0 and 1 (scoped will-change, so a long list never promotes hundreds of layers)
  • [fully-hidden] — progress reached 1; the item becomes pointer-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 narrow zone ends 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.

Properties and methods

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 pass

pin 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.

Events

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');
});

Styling

Custom properties

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;
}

Read-only custom properties

Written on each <scroll-stack-item> by the component:

Property Value
--scroll-stack-progress eased progress, 01, 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

Writing your own effect

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.

Reduced motion

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.

Browser support

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.

Accessibility

  • Items are never hidden from assistive technology or find-in-page.
  • The component writes scroll-padding-block / scroll-padding-inline equal to the resolved zone, so focus-driven scrolling lands focused elements past the fade band instead of inside it. A snap list 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.

Gotchas

  • Put borders on a wrapper, not on <scroll-stack>. A border is handled (offsets are corrected by clientTop/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's offsetParent; 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" — or effect="fade-scale", which implies it.
  • per-view overrides your own item widths. The resolved slot lands on flex-basis, which beats a width on the item — and the content is stretched to fill whatever the slot resolves to. Use item-min/item-max to 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 animate transform. They compose only because those are separate properties. An author effect that writes translate on the content element will fight the pin — use transform for your own motion.
  • Don't rewrite the pin keyframes with var() or calc(). 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 zone for scroll-padding purposes only. The zone still drives the animation exactly as before.

License

MIT


Made by Cory Schulz

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages