@threecyborgs/scroll-compress
v0.2.0
Published
Framework-free Three.js vertical scroll compression for browser apps.
Maintainers
Readme
Scroll Compress Core
Framework-free Three.js scroll compression for browser apps installed through npm.
The package is generic JavaScript. It can be used from Node-based build systems like Vite, Next, Astro, Webpack, or Rollup, but the effect itself runs in the browser because it needs DOM measurement, pointer events, canvas, and WebGL.
Install
Published to the public npm registry under the @threecyborgs scope:
npm install @threecyborgs/scroll-compress threeQuick start
Minimal, copy-paste, and guaranteed to work — no CSS and no canvas id required. The library sizes the canvas itself and runs only in the browser.
<canvas></canvas>
<script type="module">
import { createScrollCompression } from '@threecyborgs/scroll-compress';
await createScrollCompression({
canvas: document.querySelector('canvas'),
sections: [
{ id: 'a', height: 480, html: '<h1>Hello</h1><p>Real HTML, rendered in WebGL.</p>' },
{ id: 'b', height: 480, html: '<h2>Scroll</h2><p>Into the compression bands.</p>' },
],
});
</script>There is a runnable version in examples/minimal/
(npm run dev, then open /examples/minimal/).
Frameworks: call
createScrollCompressionclient-side only (insideuseEffect/onMounted/ a client component, or behind a dynamic import). It throws a clear error if run during SSR.
React and Vue
Optional wrappers handle the client-only lifecycle and cleanup for you. react
and vue are optional peer dependencies.
// React
import { useScrollCompression } from '@threecyborgs/scroll-compress/react';
function Hero() {
const { canvasRef } = useScrollCompression({
sections: [{ id: 'a', height: 480, html: '<h1>Hi</h1>' }],
});
return <canvas ref={canvasRef} />;
}<!-- Vue -->
<script setup>
import { ref } from 'vue';
import { useScrollCompression } from '@threecyborgs/scroll-compress/vue';
const canvas = ref(null);
useScrollCompression(canvas, {
sections: [{ id: 'a', height: 480, html: '<h1>Hi</h1>' }],
});
</script>
<template><canvas ref="canvas"></canvas></template>Both create the effect on mount and destroy() it on unmount. Options are read
once on mount (reset React via key).
Use
import { createScrollCompression } from '@threecyborgs/scroll-compress';
const controller = await createScrollCompression({
canvas: document.querySelector('#stage'),
sections: [
{
id: 'intro',
height: 560,
html: '<h1>Intro</h1><p>Real HTML, rendered through Three.js.</p>',
},
{
id: 'chapter',
height: 620,
html: '<h2>Chapter</h2><p>Scroll into fixed compression slots.</p>',
},
],
// Styles applied to the captured HTML. Pass CSS text directly so it works in
// any consumer app. (See "Styling the captured HTML" below for @import.)
pageStyles: `
h1, h2 { font-family: system-ui, sans-serif; margin: 0 0 12px; }
p { color: #333; line-height: 1.5; }
`,
debugGlobal: '__scrollCompressDebug',
});
// Optional: wait until images have been inlined before, e.g., screenshotting.
await controller.ready;The canvas does not need any special attributes — createScrollCompression
sets the required layoutsubtree attribute on it automatically.
API
const controller = await createScrollCompression({
canvas, // HTMLCanvasElement (or use canvasSelector)
sections, // Iterable of { id, height, html } or { element }
settings, // Optional tuning overrides
pageStyles, // CSS text applied to the captured HTML
sourceRoot, // Where section DOM is mounted (defaults to the canvas)
eventTarget, // Scroll/resize target (defaults to window)
debugGlobal, // Name to expose the controller on window for debugging
onAction, // Callback for clicks on [data-action] elements
resolveImageUrl, // (absoluteUrl) => url — rewrite/proxy image URLs
manageCanvasStyle,// Default true: library sizes the canvas (fixed, full-viewport)
});By default the canvas is styled as a fixed, full-viewport overlay so the effect
works with zero CSS. To place it in your own layout, pass
manageCanvasStyle: false and give the canvas a size yourself.
Controller:
controller.getState();
controller.scrollTo(400);
controller.jumpTo(400);
controller.refresh();
controller.destroy();
await controller.ready; // resolves after the first image-inlining passImages
The renderer rasterizes each section by serializing it to an SVG snapshot, which
means every image must be embeddable as a data: URI. This package inlines
images for you — both <img src> and inline-style background-image — fetching
each unique URL once, converting it to a data: URI, and swapping it in.
(Background images coming from external/stylesheet rules are not inlined; use
inline styles or pageStyles.) Because of that, images must be one of:
- same-origin, or
- cross-origin with CORS enabled (
Access-Control-Allow-Origin), or - already a
data:URI.
A plain <img> from a CDN that doesn't send CORS headers cannot be read in the
browser. For those, use resolveImageUrl to route through a CORS proxy or a
same-origin mirror:
await createScrollCompression({
// ...
resolveImageUrl: (url) => `/img-proxy?u=${encodeURIComponent(url)}`,
});If an image can't be inlined, it's logged once (not every frame) and falls
back to a transparent placeholder marked with
data-scroll-compress-inline-failed so you can style a fallback in pageStyles.
Styling the captured HTML
pageStyles is injected into the SVG snapshot. Prefer passing CSS text
directly (as above) or importing your stylesheet as a string via your bundler
(e.g. Vite's ?inline/?raw):
import sheet from './my-styles.css?inline';
await createScrollCompression({ /* ... */, pageStyles: sheet });@import url(...) also works, but the URL is resolved by the browser at runtime
and must be reachable from the consumer app — a relative path like
/src/theme.css only resolves inside this repo, not in your app.
Optional demo theme
The package ships an optional cosmetic theme used by the prototype. It is not required and contains global element selectors, so import it only if you want that look:
import '@threecyborgs/scroll-compress/theme.css';(The old @threecyborgs/scroll-compress/styles.css path still works but just
re-exports theme.css.)
Gotchas
- No required CSS —
manageCanvasStylesizes the canvas for you. PassmanageCanvasStyle: falseto position it yourself. - Client-side only — call it in the browser; it throws during SSR.
layoutsubtreeis automatic — you don't need to add it to the canvas.- Image origin matters — see Images. Non-CORS CDN images can't be
inlined; use
resolveImageUrl. - One warning, not thousands — a failed image logs once per URL, then degrades.
net::ERR_ABORTEDin the network panel — when you pass external image URLs, this package replaces them with inlined copies, so the original<img>request is intentionally cancelled. This is expected, not a bug. Inlining asdata:URIs up front avoids it entirely.
Agent skill
This package ships an agent skill at skills/scroll-compress/SKILL.md so AI
coding agents get versioned, correct integration guidance straight from the
dependency. Tools that follow the npm skills convention (e.g. skills-npm,
skillpm) discover it under node_modules/**/skills/*/SKILL.md and symlink it
into your agent directory:
npx skills-npm setup # links bundled skills into .cursor/skills, .claude/skills, ...Current Renderer
- One full-window Three.js renderer.
- Real HTML captured into textured planes.
- Fixed top and bottom compression bands.
- Fixed slice slots.
- Scroll changes source sampling, not slot position.
- Width never narrows.
- Partial edge slices are skipped instead of stretched.
- No full-page framebuffer.
Demo
Run the local prototype:
npm run dev -- --port 4173Then open:
http://127.0.0.1:4173/