npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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

  1. A <canvas> is fixed to one edge of the window - or of any element you pass as host - as tall as band.height.
  2. Every frame the library finds which of your targets overlap 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 their object-fit respected, then a scrim, then text rasterised through Canvas 2D.
  3. 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.
  4. 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-edge
import { 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 loop

setParams 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.textSelector to the lines you care about, or keep prose out of the band.
  • Cross-origin images need CORS. No header, no texture.
  • prefers-reduced-motion disables the whole thing by default.

The studio

npm install
npm run dev        # http://localhost:3003

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

No 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 published

License

MIT.