GitHub

@nomideusz/svelte-geometrize

Geometric image placeholders for Svelte 5 — instead of a blur, triangles resolve into the photo while it loads, geometrize.co.uk-style.

Live demo → svelte-geometrize.vercel.app · In production on szkolyjogi.pl, where 700+ listing heroes paint an instant geometric preview of the photo while it loads (open any school page, e.g. this one — hard-refresh to replay), and on kurcz.pl.

The expensive shape fitting (hill-climbing, via geometrizejs) runs at build time and emits a small ordered shape list (~1–10 KB raw, far less gzipped). Because geometrize is iterative — shape 1 is the dominant region, shape 100 is fine detail — replaying the shapes in fit order makes the placeholder visibly sharpen until the real image crossfades in. The runtime component is tiny and dependency-free.

Usage

Register the Vite plugin (build-time half):

// vite.config.ts
import { geometrize } from '@nomideusz/svelte-geometrize/vite';

export default defineConfig({
	plugins: [geometrize(), sveltekit()]
});

Then in a component:

<script lang="ts">
	import { GeometrizedImage } from '@nomideusz/svelte-geometrize';
	import placeholder from './photo.jpg?geometrize';
	import src from './photo.jpg';
</script>

<GeometrizedImage {placeholder} {src} alt="Sunset over mountains" loading="lazy" />

For typed ?geometrize imports, add to your app.d.ts:

declare module '*?geometrize' {
	const placeholder: import('@nomideusz/svelte-geometrize').GeometrizePlaceholder;
	export default placeholder;
}

// when combining with query params, put `geometrize` last (see below)
declare module '*&geometrize' {
	const placeholder: import('@nomideusz/svelte-geometrize').GeometrizePlaceholder;
	export default placeholder;
}

Options

Plugin-wide defaults via geometrize({ ... }), per-image overrides via query params:

./photo.jpg?shapes=150&alpha=160&maxSize=160&shapeTypes=triangle,ellipse&geometrize

Param order doesn't matter to the plugin, but keeping geometrize last lets the *&geometrize module declaration above type these imports.

Option Default Meaning
shapes 100 Shapes to fit — more detail, bigger payload
shapeTypes ['triangle'] Any of rectangle, rotated-rectangle, triangle, ellipse, rotated-ellipse, circle, line, quadratic-bezier
alpha 128 Shape opacity, 0–255
maxSize 128 Longest edge the image is downscaled to before fitting (the SVG scales back up losslessly)
candidateShapesPerStep 50 Fit quality vs. build speed
shapeMutationsPerStep 100 Fit quality vs. build speed

Component props beyond placeholder / src / alt: reveal ('fade' | 'pop' | 'scatter', default 'fade' — shapes fade in, scale in, or fly in from a per-shape direction), stagger (ms between shapes, default 15), shapeDuration (per-shape fade, default 400), fadeDuration (crossfade to the real image, default 600). All other props are forwarded to the <img>. The reveal is pure CSS animation, so it plays with SSR before hydration and respects prefers-reduced-motion.

Shapes are revealed coarse-first and decelerate into the fine detail, and the photo hands off with a plain dissolve on top — the placeholder never moves, blurs, or scales. Because the shapes are fitted to that exact photo, the fade reads as the final refinement step (fine detail arriving over the same structure), not as two different images swapping. The reveal runs on its own clock, so to keep it flowing right up to when the photo arrives, pace it to your expected load time with stagger / shapeDuration (a longer reveal leaves less of a gap before the handoff on slow connections).

Node API

import { generatePlaceholder, placeholderToSvg, placeholderToDataUri } from '@nomideusz/svelte-geometrize/node';

const placeholder = await generatePlaceholder('photo.jpg', { shapes: 80 });
const svg = placeholderToSvg(placeholder); // standalone SVG string

placeholderToSvg / placeholderToDataUri are also exported from the root entry (browser-safe, no geometrize/sharp dependency) for CSS backgrounds or og-images.

Dynamic images (runtime srcs, DB-stored placeholders)

The Vite plugin covers images known at build time. For photos that live behind an API — CMS content, user uploads, scraped listings — generate the placeholder once at ingest (or in a backfill script) with the Node API, store the JSON next to the record, and pass it straight to the component. This is how szkolyjogi.pl does its listing heroes:

// ingest/backfill script — one placeholder per photo, stored as JSON
import { generatePlaceholder } from '@nomideusz/svelte-geometrize/node';

const bytes = Buffer.from(await (await fetch(photoUrl)).arrayBuffer());
const placeholder = await generatePlaceholder(bytes);
await db.update(listings)
	.set({ photoPlaceholder: JSON.stringify(placeholder) })
	.where(eq(listings.id, id));
<!-- listing page — placeholder comes from the DB with the rest of the row -->
<GeometrizedImage
	placeholder={listing.photoPlaceholder}
	src="/api/photo/{listing.id}"
	alt={listing.name}
/>

A placeholder is ~2–10 KB raw (a few KB gzipped), so inlining it in server-rendered HTML is cheap — the geometric preview paints before the photo's first byte arrives, no layout shift, and sharp stays a server-side ingest dependency, never shipped to the client.

Demo

pnpm install
pnpm dev