A large image that pops in after a blank box is one of the most visible ways a page feels slow. The fix is a LQIP (Low-Quality Image Placeholder): a tiny, blurred stand-in shown instantly while the real image downloads, then faded out.
blurwind generates those placeholders at build time and inlines them as
~200-byte data: URIs, so:
- LCP improves — meaningful pixels paint immediately, no round-trip.
- CLS stays at zero — the layout box is reserved up front.
- Perceived load feels instant — the white flash is gone.
There are great tools for this already (plaiceholder, next/image's own blur). What blurwind adds is a framework-agnostic core with a pluggable strategy system and a byte-stable, zero-runtime manifest you can import anywhere — plus a generator that never breaks your build when a CDN hiccups.
blurwind's technique isn't the first thing we tried. It's what survived after shipping 67+ CDN images on a real product and hitting a wall with every "clever" alternative. The short version of that journey:
The problem. Images lazy-load from a CDN, so users stared at empty boxes until each one arrived. We wanted something nice to show instantly in the meantime.
Attempt 1 — large Base64 placeholders. Generated with plaiceholder at build
time, ~400 bytes each. It worked — the blur showed immediately and positioning was
correct — but 67 × ~400 B ≈ 32 KB of payload spent purely on placeholders.
Attempt 2 — BlurHash. Great on paper, broke in practice. BlurHash encodes an
image into a ~35-character string, dropping the payload from 32 KB → ~6 KB. But
BlurHash has to be decoded by JavaScript onto a <canvas>, which means:
- nothing shows during the initial load — the blur only appears after JS runs,
- the canvas didn't always match the image's dimensions → placeholders in the wrong position.
The entire goal is to make the page feel instant. A placeholder that waits for JavaScript defeats the purpose.
Attempt 3 — tiny Base64 + CSS background + fade. (This is what blurwind does.) Back to inline Base64, but make it tiny and render it with zero JavaScript:
- a very small blurred image (WebP by default; PNG/AVIF/JPEG supported), encoded as
a
data:URI — a couple hundred bytes, - painted as a CSS background, so it renders immediately, on the server, with correct layout and positioning,
- when the real image finishes loading, it fades in smoothly over the blur.
On that project the payload dropped from ~32 KB → ~13 KB (≈58% smaller), the
blur was visible instantly, positioning stayed correct, and it added no new
dependencies (sharp was already installed).
| Large Base64 | BlurHash | blurwind (tiny Base64 + fade) | |
|---|---|---|---|
| Payload (67 images) | ~32 KB | ~6 KB | ~13 KB |
| Visible without JS | ✅ | ❌ | ✅ |
| Renders on the server | ✅ | ❌ | ✅ |
| Correct positioning | ✅ |
|
✅ |
| Smooth fade-in | ❌ | ❌ | ✅ (~0.4s) |
| Extra dependencies | plaiceholder |
blurhash + canvas |
none (sharp) |
Size and format are configurable. The default
blurstrategy renders a10×10WebP; drop it to4×4for the absolute smallest payload, or switch formats per project. blurwind simply productizes attempt 3 — and makes the strategy pluggable so BlurHash, dominant-colour, or SVG can be opt-in choices, never the forced default.
For a Next.js app, it's two commands:
pnpm add @blurwind/next # the <Image> component (brings @blurwind/runtime with it)
pnpm add -D @blurwind/cli # the `blurwind generate` command (brings @blurwind/core with it)That's everything. You don't install @blurwind/core or @blurwind/runtime
yourself — they come along automatically as dependencies of the two above.
Not on Next.js? Use
@blurwind/clito generate the manifest and read it with@blurwind/runtime, or build a custom pipeline directly on@blurwind/core. Adapters for other frameworks are on the roadmap.
blurwind is split into small pieces so you only ship what you use — a Next.js app pulls in React; a plain build script doesn't.
| Package | Role | You install it? |
|---|---|---|
@blurwind/next |
Drop-in <Image> for Next.js: automatic placeholder + fade-in. |
✅ directly |
@blurwind/cli |
blurwind generate / init for your build pipeline. |
✅ directly (dev) |
@blurwind/runtime |
Zero-dependency browser helper (resolve placeholder + animation). | ⤵ comes with next
|
@blurwind/core |
The build-time engine: scan → load → render → manifest. Framework-free. | ⤵ comes with cli
|
After the two installs above:
1. Configure — blurwind.config.ts:
import { defineConfig } from '@blurwind/cli'
export default defineConfig({
scanDir: 'app',
baseURL: process.env.ASSET_CDN_PATH, // omit to read from local `public/`
output: {
dir: 'app/constants',
file: 'blur-data.ts',
tsExportName: 'BLUR_DATA_URLS'
}
})2. Generate — wire it into your build:
blurwind generate auto-loads your .env / .env.local (Next-style
precedence) before reading the config, so process.env.ASSET_CDN_PATH above just
works from the command line — no extra setup. If it finds image references but
produces nothing (e.g. a mistyped baseURL), it prints one actionable error
instead of silently shipping an empty manifest, and fails the build under
--strict or CI.
3. Render — bind the manifest once, use it everywhere:
Shortcut:
npx blurwind componentscaffolds this file for you — with'use client'already in place and the manifest import wired from your config. The example below is exactly what it generates; write it by hand if you prefer.
// components/Image.tsx
'use client'
import { createBlurImage } from '@blurwind/next'
import { BLUR_DATA_URLS } from '@/app/constants/blur-data'
export default createBlurImage({
manifest: BLUR_DATA_URLS,
baseURL: process.env.ASSET_CDN_PATH
})Why
'use client'?@blurwind/nextis a client module (it runs the blur→image transition with React state), socreateBlurImageis a client reference — and the file that calls it must be a Client Component. Without the directive, the App Router treats this file as a Server Component and the build fails with:Attempted to call createBlurImage() from the server but createBlurImage is on the client. It's not possible to invoke a client function from the server...This does not disable SSR: your
<Image>is still server-rendered into the HTML — SEO, LCP, and lazy-loading are unaffected — the client boundary only powers the blur-to-image transition.
import Image from '@/components/Image'
<Image src="/hero.webp" alt="Hero" width={1200} height={600} />
// blurred placeholder shows instantly via next/image's native blurIt's a genuine drop-in: every next/image prop — fill, className, style,
sizes, width/height — passes straight through (so <Image fill className="object-cover"> works unchanged). Prefer an animated cross-fade? Pass
animation: { type: 'fade' } to createBlurImage.
source files @blurwind/core your app
┌──────────────┐ ┌───────────────────────────────┐ ┌──────────────────┐
│ *.tsx *.mdx │ │ scanner → loader → strategy → │ │ import manifest │
│ "/hero.webp"│──▶│ engine → manifest-writer │──▶│ <Image src=… /> │
└──────────────┘ └───────────────────────────────┘ │ @blurwind/next │
│ ▲ │ + @blurwind/ │
sharp │ │ Strategy plugins │ runtime │
▼ │ (blur, …) └──────────────────┘
tiny blurred data: URI
- Scanner walks your source, extracting image references via a configurable pattern.
-
Loader fetches each source (remote URL, CDN base, or local
public/), with retries. -
Strategy (default
blur) downscales + blurs + encodes viasharp, emitting adata:URI. -
Manifest writer emits a deterministic
src → placeholdermap (.tsand/or versioned.json). -
Runtime + adapter resolve the placeholder and hand it to
next/image(native blur by default, or an optional motion cross-fade).
Failures are absorbed: an unreachable source reuses its previous placeholder, and if
nothing can be generated the existing manifest is left untouched — your build never fails
because of blur generation. A run that finds references but produces nothing still
prints one actionable error, and --strict (or CI) turns it into a non-zero exit.
Strategies are plugins. Register your own to produce dominant-colour, BlurHash, SVG, or shimmer placeholders without touching the engine:
import { createGenerator, resolveConfig, type Strategy } from '@blurwind/core'
const colorStrategy: Strategy<'color'> = {
name: 'color',
async generate({ bytes, sharp }) {
const { dominant } = await sharp(bytes).stats()
const hex = `#${[dominant.r, dominant.g, dominant.b]
.map(c => c.toString(16).padStart(2, '0'))
.join('')}`
return { value: hex, meta: { kind: 'color' } }
}
}
await createGenerator(resolveConfig({ strategy: 'color' })).use(colorStrategy).run()See @blurwind/core for every option. Highlights:
| Option | Default | Notes |
|---|---|---|
scanDir |
'src' |
Directory or directories to scan. |
match |
broad image-URL regex | Narrow to your CDN/asset paths. |
baseURL |
'' (or $BLURWIND_BASE_URL) |
Prefix for relative sources; empty → local public/. |
blur |
{ size: 10, radius: 1.5, format: 'webp', quality: 40 } |
Built-in strategy tuning. |
output |
src/generated/blur-manifest.ts |
formats: ['ts' | 'json']. |
concurrency |
4 |
Parallel images. |
cache |
{ enabled: false, dir: 'node_modules/.cache/blurwind' } |
Skip fetch + render for unchanged sources across builds. |
failSafe |
true |
Never throw; degrade gracefully. |
Attempted to call createBlurImage() from the server but createBlurImage is on the client
The file that calls createBlurImage(...) is being treated as a Server Component.
@blurwind/next is a client module, so that file must start with 'use client' —
see Quick start step 3. This doesn't disable SSR: the
<Image> is still server-rendered into the HTML; the client boundary only powers
the blur-to-image transition.
- Strategies: dominant colour, BlurHash, SVG trace, shimmer.
- More adapters: React (framework-neutral), Vue, Svelte, Astro.
- Bundler plugins: Vite, Webpack, Rollup.
- Docs site + interactive playground.
pnpm install
pnpm build # build every @blurwind/* package (tsup)
pnpm typecheck # tsc --noEmit across packages
pnpm changeset # record a change for releaseMIT © Benyamin Khodadadi
{ "scripts": { "prebuild": "blurwind generate" } }