blurst
v0.1.0
Published
Instant image lightbox for React. Opens on tap with a blurred preview, bursts to full resolution, pinch to zoom, swipe to dismiss.
Maintainers
Readme
blurst
The React image lightbox that opens on the first frame.
Live demo and playground · npm
Tap an image and the viewer is already open — showing the thumbnail the browser has cached, blurred, until the full-resolution file decodes and takes its place. No spinner on black, no empty frame, no layout jump.
npm i blurstimport { BlurstImage } from "blurst";
<BlurstImage src="/photo-thumb.jpg" full="/photo-full.jpg" alt="Lysefjord" />;That is the whole setup. No CSS import, no provider, no dependencies.
Why
Most lightboxes mount an empty overlay and then start downloading, so a tap on a slow connection gives you a spinner on black. Blurst treats the thumbnail already painted on the page as the first frame of the full image:
| | |
|---|---|
| 0 ms to first paint | The already-decoded thumbnail is the placeholder. Nothing is fetched to open. |
| 7 kB gzipped | One file, zero dependencies, no stylesheet to import. |
| Native gestures | Pinch, double-tap, pan, swipe to dismiss, swipe between images. |
| Any image | A 32 px avatar, a grid tile, a full-bleed hero, or markup you don't own. |
| Accessible | Labelled modal dialog, full keyboard control, honours prefers-reduced-motion. |
| SSR-safe | Ships its own "use client" boundary; drops into the Next.js App Router. |
Setups
import { BlurstImage } from "blurst";
<BlurstImage
src="/photo-thumb.jpg"
full="/photo-full.jpg"
alt="Lysefjord, Norway"
caption="Lysefjord, Norway"
className="rounded-lg"
/>;Every <img> prop still works — srcset, sizes, loading, className, onLoad.
import { BlurstGroup, BlurstImage } from "blurst";
<BlurstGroup>
{photos.map((p) => (
<BlurstImage key={p.id} src={p.thumb} full={p.full} alt={p.alt} caption={p.caption} />
))}
</BlurstGroup>;Triggers inside a group are ordered by their position in the document. Swipe sideways, use the arrow keys, or click the chevrons.
import { Blurst } from "blurst";
<Blurst src="/receipt-full.jpg" thumb="/receipt-thumb.jpg" caption="Invoice 4021">
<button className="card">…</button>
</Blurst>;Wraps a single child — a card, a CSS background, a next/image, a canvas.
import { BlurstAuto } from "blurst";
<article dangerouslySetInnerHTML={{ __html: post.html }} />
<BlurstAuto selector="article img" gallery />Attaches by CSS selector to images already in the DOM. Nothing about their markup
changes. Point full at a data-blurst attribute, an enclosing image link, or let
it take the largest candidate from srcset.
import { BlurstProvider, useBlurst } from "blurst";
// once, near the root
<BlurstProvider options={{ blur: 20 }}>{children}</BlurstProvider>;
// anywhere below it
const { open } = useBlurst();
open({ items: photos, index: 2 });Where the full-resolution image comes from
Pass full and it is used. Otherwise Blurst resolves it, in order:
- a
data-blurst/data-blurst-fullattribute on the image - an enclosing
<a>whosehrefpoints at an image file - the largest candidate in
srcset - the image's own source — the viewer then opens sharp with no second request
Downloading starts on pointer or touch intent, a beat before the click lands. Set
preload: "open" to wait for the tap, or preload: "eager" to fetch on idle.
Override the whole thing with resolveFull: (el) => string.
Options
Set them per image, per group, or once on BlurstProvider. The nearest wins.
| Option | Default | |
|---|---|---|
| transition | "zoom" | Flies from the thumbnail, "fade", or "none" |
| openDuration / closeDuration | 260 / 200 | ms |
| blur | 16 | Placeholder blur radius, px |
| backdrop | "rgba(0,0,0,0.92)" | Backdrop colour |
| backdropBlur | 0 | backdrop-filter blur, px |
| padding | 24 | Gap to the viewport edge, px |
| preload | "hover" | "hover" · "open" · "eager" |
| resolveFull | — | Your own trigger → URL function |
| swipeToDismiss | true | Drag the image away to close |
| swipeAxis | "any" | Restrict the dismiss drag to one axis |
| dismissThreshold | 96 | Drag distance that commits to a close, px |
| pinchToZoom | true | Two-finger pinch, anchored at the midpoint |
| doubleTapToZoom | true | Double tap or click zooms at that point |
| wheelToZoom | true | Trackpad pinch and ctrl+wheel |
| maxScale / doubleTapScale | 5 / 2.5 | Zoom bounds |
| swipeToNavigate | true | Horizontal swipe moves through a group |
| loop | true | Wrap at the ends of a group |
| closeOnBackdropClick | true | |
| keyboard | true | Escape, arrows, +, -, 0 |
| closeButton / counter / captions | true | Chrome toggles |
| loader | — | Custom spinner, or false |
| zIndex | 9999 | |
| container | document.body | Portal target |
| ariaLabel | "Image viewer" | |
| onOpen / onClose / onIndexChange / onZoom | — | Callbacks |
Keyboard and accessibility
The overlay is a labelled role="dialog" with aria-modal. Escape closes,
←/→ move through a group, +, - and 0 control zoom. Body scroll is locked
while open, with scrollbar-width compensation. prefers-reduced-motion: reduce
turns off every transition.
Requirements
React 18 or 19, and react-dom. Both are peer dependencies. TypeScript types are
bundled.
License
MIT © Cameron Clark
