- TypeScript 82%
- JavaScript 10.8%
- HTML 7.1%
- Just 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| demo | ||
| src | ||
| test | ||
| .gitignore | ||
| index.html | ||
| justfile | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.build.json | ||
| tsconfig.json | ||
| vite.lib.config.ts | ||
@bl0cka.de/blob
Organically morphing decorative blob with gradient or image fill. Zero
runtime dependencies; ships compiled ESM with type declarations — no
transpilePackages or special bundler setup needed.
npm install @bl0cka.de/blob
Usage
import { createBlob } from '@bl0cka.de/blob'
const blob = createBlob(document.querySelector('#hero-blob')!, {
fill: {
type: 'gradient',
stops: [
{ offset: 0, color: '#8b5cf6' },
{ offset: 1, color: '#22d3ee' },
],
angle: 45,
},
animate: 'hover',
})
The blob renders a <canvas> (or <svg>, see renderer) that fills its
container (width/height: 100%) — size it via the container. Non-square
containers are filled edge-to-edge by default (fit: 'fill'): the shape
stretches organically over the rectangle while image fills and gradient
angles stay undistorted. Use fit: 'contain' for a centered square blob.
By default the animation runs in a shared Web Worker on an OffscreenCanvas:
the morph keeps its frame rate even while the main thread is busy. Where
workers or OffscreenCanvas aren't available, it falls back to a main-thread
canvas automatically.
Options
| Option | Default | Description |
|---|---|---|
fill |
violet→cyan 45° gradient | { type: 'gradient', stops, shape?: 'linear'|'radial', angle? } or { type: 'image', src } (covers the blob like background-size: cover) |
points |
8 |
Anchor points sampled from the noise contour (min 3). 6–10 = blobby, more = busier outline |
irregularity |
0.35 |
Radial variation, 0..1 |
frequency |
1 |
Spatial noise frequency — higher = more lobes |
speed |
0.15 |
Morph speed (noise time-units per second) |
seed |
random | Same seed → same shape sequence |
animate |
'always' |
'always' | 'hover' | 'scroll' | 'manual' |
triggerTarget |
the container | Hover mode: element whose hover animates the blob |
scrollDebounceMs |
150 |
Scroll mode: ms after the last scroll event before winding down |
attackMs |
300 |
Ramp-up duration for play / trigger start |
decayMs |
800 |
Ramp-down duration for pause / trigger end |
easing |
'ease-in-out' |
Easing for start/stop ramps: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' or a custom (t: number) => number mapping 0→0, 1→1 |
transitionMs |
400 |
Crossfade duration for live shape changes |
pauseWhenOffscreen |
true |
Suspend all work while the blob is outside the viewport |
fit |
'fill' |
'fill' stretches the shape over non-square containers (fills stay undistorted); 'contain' keeps a centered square. Create-time only |
renderer |
'worker' |
'worker' runs animation + drawing in a Web Worker (OffscreenCanvas) — immune to main-thread jank; auto-falls back to 'canvas' where unsupported. 'canvas' renders on the main thread (cheaper than SVG; also accepts non-CORS image fills, which 'worker' cannot fetch). 'svg' is resolution-independent: always crisp when zooming or printing. Create-time only, not switchable via setOptions |
Controller
blob.play() // ramp the animation up (eased, attackMs)
blob.pause() // ramp it down (eased, decayMs)
blob.setOptions({ … }) // live-update any option
blob.destroy() // remove SVG, listeners, observer
blob.element // the HTMLCanvasElement or SVGSVGElement
setOptions never causes a visual jump: shape parameters (points,
irregularity, frequency, seed) crossfade over transitionMs while the
morph keeps running; everything else applies seamlessly on the next frame.
In trigger modes, play()/pause() act as an override until the next
trigger event takes back control.
When nothing animates (and during off-screen periods), the shared
requestAnimationFrame loop is fully stopped — idle blobs cost nothing.
This applies in the worker too.
Worker renderer notes
- All worker-rendered blobs share one worker; it is terminated when the last one is destroyed.
- A custom
easingfunction can't cross the worker boundary directly — it is sampled into a 65-entry lookup table and interpolated linearly, which is visually indistinguishable for monotone easings. - Image fills are
fetched inside the worker, so the URL must be CORS-accessible. Userenderer: 'canvas'for same-origin-restricted images. - The worker code is inlined into the bundle and started from a blob URL, so
no bundler configuration or asset hosting is needed. Pages with a strict
Content-Security-Policy must allow
worker-src blob:(otherwise the blob falls back to the main-thread canvas renderer).
Development
npm install
npm run dev # vite demo at http://localhost:5173, auto-reloads
npm run check # tsc --noEmit
npm test # jsdom behavior suite
npm run build # library build → dist/ (bundle + .d.ts, worker inlined)
npm run build:demo # demo site build → dist-demo/
Publishing runs check, test and build automatically (prepublishOnly).