scroll-edge
v0.2.0
Published
WebGL edge effects for the bottom or top of the viewport: glass, fade, distortion and liquid-glass refraction driven by page scroll
Maintainers
Readme
scroll-edge
A band pinned to the bottom (or top) edge of the window. Whatever scrolls into it is redrawn there by one WebGL pass, so the page dissolves, frosts, refracts or melts on its way out of the viewport: liquid glass with a lit seam, a fade into the page colour, turbulence and melt, chromatic dispersion, and a smear driven by scroll speed.
Not a slider, not a scroll library. It does not move anything, it does not hijack the scroll: it only changes what the last few hundred pixels of the window look like. 143 presets in seven families, zero dependencies, one JS file.
import { ScrollEdge } from 'scroll-edge';
const fx = new ScrollEdge({ targets: '.card' });How it works
- A
<canvas>is fixed to one edge of the window - or of any element you pass ashost- as tall asband.height. - Every frame the library finds which of your
targetsoverlap that strip, and draws them into an offscreen buffer at their real screen position: the card background as a rounded rectangle, then images, video and canvases with theirobject-fitrespected, then a scrim, then text rasterised through Canvas 2D. - One screen-space pass reads that buffer and writes the visible band: an edge ramp, a wavy surface, a lit seam, a multi-sample spectral smear, a defocus, a fade into the background, and a velocity smear scaled by how fast the page is moving.
- Above the band nothing is touched. The DOM there is the real DOM.
The canvas rebuilds your content, it does not photograph it, so its copy is
never bit-identical: no box-shadow, text re-typeset through Canvas 2D, images
resampled by the GPU rather than the browser. At full opacity from its first
pixel that difference reads as a cut running across the page. So the band does
not start opaque - it crossfades in, and the crossfade lives entirely in the
margin above the zone (1 - band.zone), the strip where the displacement is
still exactly 0. That ordering is the point: by the time anything moves, the
canvas is fully opaque and the DOM underneath is covered, so the page is never
showing a sharp copy and a displaced one at the same time.
Everything that alters a pixel rides one depth ramp, and that includes the velocity smear: on a hard flick a smear that started at full length would meet the sharp page along a straight line, whatever the compositing did.
The band is opaque - it clears to background and paints your targets on
top. That is what makes the effect possible without reading pixels back out of
the browser, and it is the one thing to design around:
Everything inside the band has to be either the page background or a target. Point it at a gallery, a media grid, a card list, a footer strip. Anything else that scrolls through the band - a paragraph you did not list, a section with a different background - is painted over.
The band hides itself the moment no target overlaps it, so the rest of the page is untouched and the GPU is idle.
Install
Bundler (Vite / webpack / Next)
npm i scroll-edgeimport { ScrollEdge } from 'scroll-edge';
import { presets } from 'scroll-edge/presets';
const fx = new ScrollEdge({
targets: '.card',
edge: 'bottom',
...presets['glass-liquid'].params,
});Images drawn into the band are uploaded as WebGL textures, so cross-origin images need CORS headers and the attribute:
<img src="https://cdn.example.com/shot.jpg" crossorigin="anonymous" alt="">Without it the browser refuses the upload, the layer is skipped and a warning is logged. Same-origin images need nothing.
From a CDN, no bundler (plain <script>, Webflow, Framer, WordPress)
<script src="https://unpkg.com/scroll-edge/dist/scroll-edge.iife.js"></script>
<script>
new ScrollEdge({ targets: '.card', ...ScrollEdge.presets['glass-liquid'].params });
</script>Or with no JavaScript of your own, and nothing to mark up. Tag the section and
the bundle wires itself up; with no data-targets the section's own children
are what gets drawn:
<section data-scroll-edge-root data-preset="fade-dissolve">
<article>...</article>
<article>...</article>
</section>
<script src="https://unpkg.com/scroll-edge/dist/scroll-edge.iife.js" data-auto></script>Recognised attributes on the root: data-preset, data-targets, data-edge
(bottom / top / both), data-height, data-background.
Why there is a list at all
The band does not photograph the page - no browser will hand over the rendered pixels of arbitrary DOM. It rebuilds each target instead, from its background colour, the images and video inside it and its text rasterised through Canvas 2D, and warps that. So it has to be told which elements to take apart.
targets: 'auto' answers the usual case without any marking: it takes the
element children of the box the band was pointed at - the container if you
scoped it, else the host, else the page. One level only, and deliberately:
walking the tree would pick up every wrapper on the way down and redraw the
same pixels several times over. Anything more particular is what a selector is
for.
Parameters
Everything is optional. Defaults in ScrollEdge.defaults.
Top level
| Option | Default | What it does |
| --- | --- | --- |
| enabled | true | Set false to build the instance inert. |
| mode | 'glass' | 'glass' is the full edge zone; 'motion' drops it and leaves only the scroll-velocity smear, which costs nothing while the page is still. |
| edge | 'bottom' | 'bottom', 'top' or 'both'. One canvas per side. |
| targets | '[data-scroll-edge]' | Selector, element, or list of elements to draw into the band. 'auto' takes the children of the box the band sits on, so nothing needs marking up. |
| container | null | Scope: only targets inside these elements are collected. |
| host | null | The box the band is pinned to. null is the window. Point it at a scrollable element - a panel, a modal, a fixed-height list - and the band sits on that element's edge instead, following it as the page moves. Pair it with scroller so the velocity comes from the element that actually scrolls. |
| scroller | null | Scroll container to read the offset from. null is the window. |
| getScroll | null | Custom reader, e.g. () => lenis.scroll. Wins over scroller. |
| dpr | 2 | Max device pixel ratio for the canvas. Drop to 1.5 on weak GPUs. |
| background | 'auto' | The colour the band clears to. 'auto' walks up from the first target's parent and takes the first painted background. |
| radius | null | Corner radius in px; null reads border-radius off each target. |
| zIndex | 30 | Keep it under any fixed navigation. |
| respectReducedMotion | true | Skip the effect entirely under prefers-reduced-motion. |
| preserveDrawingBuffer | false | Needed for toDataURL(). |
band - the strip itself
| Option | Default | What it does |
| --- | --- | --- |
| height | 0.3 | <= 1 is a fraction of the viewport, above that it is px. |
| offset | 0 | Push the band away from the edge, in px. |
| zone | 0.85 | How much of the band distorts, measured from the edge. Below 1 the inner part stays untouched - that is what hides the join with the real DOM. |
| blend | 1 | How much of the margin above the zone (1 - zone) the canvas fades in over. The whole ramp sits in the strip where nothing is displaced yet, so the DOM is never visible under a copy that has already moved. 0 puts the hard edge back; to widen the fade, lower zone. |
| falloff | 1.4 | Exponent of the ramp. Higher keeps the distortion at the edge. |
| fade | 0.35 | How strongly the outermost pixels fade into background. |
| turbulence | 0.1 | Noise-driven banding. Keep low for clean glass. |
| turbulenceSize | 140 | Size of the noise bands, in px along the edge. |
warp - displacement
| Option | Default | What it does |
| --- | --- | --- |
| amount | 16 | Base pull towards the edge inside the zone, in px. |
| style | 'prism' | 'prism' separates channels, 'stretch' is a smooth achromatic pull. |
| squeeze | 0 | Above 0 the content compresses into the edge; below 0 it opens up. |
| lateral | 0.3 | How much of the wave also moves the content sideways. This is what reads as liquid rather than as a vertical blind. |
| wave | true | The wavy surface. |
| kind | 'glass' | 'glass' is a soft two-harmonic sine, 'ripple' is tighter and faster. |
| strength | 10 | Wave amplitude, px. |
| length | 300 | Wavelength along the edge, px. |
| speed | 0.3 | Animation speed. 0 freezes the wave - and with it the whole per-frame animation. |
dispersion - the smear
| Option | Default | What it does |
| --- | --- | --- |
| amount | 70 | Length of the spectral smear, px. |
| samples | 48 | Upper bound on samples per pixel. The shader picks the real count from the actual travel, so only the outermost pixels ever pay the full price. Below ~24 long smears break into ghost copies. |
| spectrum | 0.5 | 0 is a plain blur, 1 is full channel separation. |
| hue | 0 | Rotation of the spectrum, degrees. |
| blur | 26 | Extra symmetric defocus, px. |
| aura | 0.1 | Saturation bloom inside the zone. |
scroll - velocity smear
| Option | Default | What it does |
| --- | --- | --- |
| amount | 60 | Smear length at full scroll speed, px. 0 disables it. |
| blur | 10 | Extra defocus at full speed, px. |
| spectrum | 0.35 | Channel separation inside the smear. |
| sensitivity | 0.35 | How fast scrolling saturates the effect. Higher is twitchier. |
| damping | 0.3 | Smoothing of the velocity signal. Lower ramps in and out more softly. |
resolve - the grid the band comes apart into
Not a displacement like the rest: the band is cut into cells, each cell is measured for brightness, and then redrawn. It rides the same depth ramp as everything else, so the picture comes apart gradually rather than switching over at a line, and it composes with the warp - the grid travels with the distortion instead of sitting on top of it like a filter.
With driver: 'depth' the ramp is a function of position: fully resolved at the
very edge, untouched at the inner boundary. Which way that reads is the scroll
direction, not a setting - on a bottom band, scrolling down, content enters at
the edge as blocks and firms up as it rises.
A band, or a real reveal
A band at the edge means a card crossing it is part canvas and part DOM: the
effect sits on the element. For a reveal proper the canvas has to own the
element outright - give the band the whole host (band.height: 1, zone: 1,
blend: 0) and it does. The DOM underneath is covered everywhere, so nothing
can show through unresolved, and the card is made of the grid rather than
wearing it.
Two conditions come with it, and neither is optional. The host has to clip its
own content, so the canvas and the DOM are cut at the same line - a scroll
container does that by itself. And background has to be the section's colour,
because the canvas now paints all of it.
With driver: 'element' the timing belongs to the card instead of the screen. A
second, quarter-size buffer is painted with one flat rect per target carrying how
far that target is through its own crossing, and the pass reads its amount from
there. The card is then one thing: it comes apart and back together as a unit,
and curve: 'through' gives the whole arc in a single pass - whole, dust, whole
again. The crossing is measured over the band plus the card's own height, because
a card is usually taller than the band and anything normalised on the band alone
would sit pinned at one end for the entire pass.
| Option | Default | What it does |
| --- | --- | --- |
| style | 'off' | 'pixel' redraws each cell as a flat block. |
| driver | 'depth' | What decides how far a pixel is resolved. 'depth' uses its position in the band, so the whole page comes apart along the same line. 'element' uses how far its own target has travelled across the band, so each card comes apart and back together as one thing, on its own clock. |
| curve | 'in' | driver: 'element' only, read across one target's whole crossing: 'in' arrives as dust and assembles, 'out' is whole and comes apart, 'through' does both - whole, dust, whole. |
| amount | 0 | How far the resolve goes at the edge, 0..1. |
| reveal | 0 | How much the cells drop out in patches as the resolve runs. The threshold is smooth noise over the cell grid rather than one random value per cell, so neighbours agree and the picture arrives in spreading patches instead of static. |
| size | 12 | Cell size in px. |
| color | '#ffffff' | Ink the blocks are mixed towards. |
| tint | 0.6 | How much of the picture's own colour bleeds into the ink. 1 keeps the picture's colours, 0 is one flat ink. |
| gamma | 1 | Contrast of the brightness reading. Above 1 pushes to the ends of the range. |
seam - the lit line (off by default)
| Option | Default | What it does |
| --- | --- | --- |
| enabled | false | The signature of thick glass, and a strong look - opt in. |
| offset | 0 | Where it sits, as a fraction of the band height from the edge. |
| width | 1 | Thickness, px. Try 26. |
| glow | 0 | Brightness. Try 0.35. |
| refraction | 0 | Lens-like displacement around it, px. Try 30. |
content - what gets drawn
| Option | Default | What it does |
| --- | --- | --- |
| text | true | Rasterise target text so it distorts with the artwork. Turn it off and text simply disappears behind the band. |
| textSelector | null | Selector for text layers; null auto-detects leaf text nodes. |
| textBoost | 0 | Extra ghost tail on text, on top of the smear everything else gets. Off by default: it is drawn as a run of offset copies, and on high-contrast text that reads as the same words printed twice. Keep it at or below 1. |
| scrim | 0 | Gradient scrim over each target, 0..1. |
| scrimColor | '#000000' | Its colour. |
Presets
143 compositions in seven families, in scroll-edge/presets:
| Family | Count | Character |
| --- | --- | --- |
| glass | 20 | Liquid glass: refraction under a lit seam. |
| fade | 21 | The content dissolves into the page background. fade-progressive is blur-and-dissolve only, with nothing displaced. |
| distort | 20 | Turbulence, ripple, melt, squeeze. The loud end. |
| motion | 20 | Scroll-velocity smear only. Free while the page is still. |
| editorial | 20 | Restrained, tuned so body copy stays readable. |
| noir | 20 | For dark pages. Each one sets its own background. |
| pixel | 22 | Blocks at the edge, the whole picture further in. |
import { presets, presetFamilies } from 'scroll-edge/presets';
presets['glass-liquid'];
// { label: 'Liquid', note: '...', family: 'glass', params: { ...full config } }
presetFamilies.map((f) => `${f.label}: ${f.presets.length}`);A preset is a plain params object, so it merges with your own settings:
new ScrollEdge({
targets: '.card',
...presets['distort-melt'].params,
band: { height: 220 }, // px instead of the preset's fraction
});Noir presets carry a background, because a dark band on a light page is a grey
rectangle. Keep the page and the preset in agreement.
Runtime API
const fx = new ScrollEdge({ targets: '.card' });
fx.setParams({ dispersion: { amount: 120 }, seam: { enabled: true, glow: 0.4 } });
fx.refresh(); // re-read the DOM: targets, textures, background, geometry
fx.resize(); // re-measure the bands only
fx.canvas('bottom'); // the canvas element, or null
fx.toDataURL(); // needs preserveDrawingBuffer: true
fx.destroy(); // removes the canvases, drops the GL contexts, stops the loopsetParams deep-merges, and rebuilds the shader when dispersion.samples
changes. edge is the one option it cannot move - the number of canvases is
decided at construction, so recreate the instance instead.
Call refresh() after the DOM changes: new cards, a filtered grid, a theme swap
(the card background colour is baked into a layer), a font that only just landed.
Images that load late re-upload themselves - each layer listens for its own
load / loadeddata - but their geometry is measured once.
With a smooth-scroll library
The velocity signal comes from whatever getScroll returns, so a virtual
scroller works as long as you point at its value:
const lenis = new Lenis();
const fx = new ScrollEdge({ targets: '.card', getScroll: () => lenis.scroll });The band itself is position: fixed and reads live element rects, so it follows
transformed content without any extra wiring.
Performance
- One canvas per edge. Two GPU passes per frame: the targets into a buffer, then one full-band pass.
- The pass early-outs on any pixel that is untouched - no zone, no velocity, no seam - so a band that is mostly still costs almost nothing.
- The sample count follows the real travel in device pixels, capped by
dispersion.samples. A 48-sample setting only ever runs 48 taps on the outermost pixels. - Culling runs on cached document-space extents, so a hundred cards cost a
hundred number comparisons per frame; only the few actually inside the band get
a live
getBoundingClientRect(). - Nothing renders while no target overlaps the band, and the canvas is hidden rather than painted.
On the demo page - 18 cards, both edges active, dpr 2 - a continuous scroll
holds 60fps with a 16.7ms median frame. If you need more headroom: lower dpr,
lower dispersion.samples, shorten band.height, or switch to mode: 'motion'.
Limitations
- The band is opaque. Anything inside it that is not a target or the page background gets painted over. This is the trade for not reading the page back out of the browser.
- Backgrounds have to agree. One colour per instance. A section boundary
passing through the band shows the instance's colour, not the section's. Use
one instance per section (with
container) if the page changes colour. - Targets are rebuilt, not screenshotted. Backgrounds, images, video, canvases, text and a scrim are drawn. Borders, box-shadows, gradients, SVG, iframes and pseudo-elements are not - a target that relies on them will look different inside the band.
- Text is re-typeset, not copied: Canvas 2D wrapping, one colour, no inline
markup. Fine for captions and headings, not for a page of prose - set
content.textSelectorto the lines you care about, or keep prose out of the band. - Cross-origin images need CORS. No header, no texture.
prefers-reduced-motiondisables the whole thing by default.
The studio
npm install
npm run dev # http://localhost:3003A React studio (shadcn/ui) with the live stage on top and all 143 presets under
it: apply one with a click, tune every knob in the side panel, star the ones you
like, and copy the result as HTML, an npm import or CDN tags. It lives in
src/app/ and src/components/, and none of it ships with the library - files
in package.json only carries src/*.js, the types, the CSS and dist/.
The stage is itself the demo for host: a scrollable box with the band on its
bottom edge, the velocity read from its own scrollTop. It scrolls itself so a
preset shows its motion without you touching anything, and stops while the
pointer is over it.
Building the standalone bundle
node build.mjs # -> dist/scroll-edge.iife.js + dist/scroll-edge.cssNo bundler: the script wraps the ES modules into a classic <script> file that
exposes window.ScrollEdge with presets and presetFamilies attached. It runs
automatically on npm publish.
Structure
src/scroll-edge.js the library: shaders, layers, bands, public class
src/scroll-edge.css optional styles (stacking, reduced motion)
src/scroll-edge.d.ts types
src/edge-presets.js 143 presets in seven families
src/edge-controls.js declarative control schema, the panel is built from it
build.mjs ESM -> IIFE bundle
index.html + src/app/ the studio (React, shadcn/ui), not publishedLicense
MIT.
