diff --git a/.changeset/calm-owls-blur.md b/.changeset/calm-owls-blur.md
new file mode 100644
index 0000000..79c6e2d
--- /dev/null
+++ b/.changeset/calm-owls-blur.md
@@ -0,0 +1,7 @@
+---
+"@solidjs/image": minor
+---
+
+Add an opt-in BlurHash preview. Set `placeholder: { type: "blurhash" }` in the plugin and install `blurhash`, which is an optional peer dependency. The number of components is picked per image from its aspect ratio, so there is nothing else to configure.
+
+The server paints the average color of the image, and the browser decodes the hash into a blur. Remote images can return `{ hash, color }` from `transformURL` and get the same preview.
diff --git a/README.md b/README.md
index 90e6615..1c122e6 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@ Requirements:
- `solid-js` 1.9.9 or newer, and Vite 8 or newer. Both are peer dependencies.
- Node 24 or newer for the Vite plugin. It uses [`sharp`](https://sharp.pixelplumbing.com) to process images.
+- [`blurhash`](https://github.com/woltapp/blurhash) 2 or newer, only for the BlurHash preview. It is an optional peer dependency.
## Setup
@@ -207,6 +208,12 @@ interface SolidImagePlaceholder {
color: string;
}
+interface SolidImageBlurhashPlaceholder {
+ hash: string;
+ color: string;
+ decode: (hash: string, width: number, height: number) => Uint8ClampedArray;
+}
+
interface SolidImageVariant {
path: string;
width: number;
@@ -249,7 +256,7 @@ Handles imports ending in `?image`.
| `input` | `SolidImageFormat[]` | `["png", "jpeg", "webp"]` | Source formats to process. Other files are left alone. |
| `output` | `SolidImageFormat[]` | `["png", "jpeg", "webp"]` | Formats to emit. |
| `publicPath` | `string` | Vite's `publicDir` | Directory the dev server writes processed files to. |
-| `placeholder` | `boolean \| { size?: number }` | `true` | Inline preview of the image. Set a `size` in pixels, or `false` to skip it. |
+| `placeholder` | `boolean \| { size?: number } \| { type: "blurhash" }` | `true` | Preview shown while the image loads. See [BlurHash preview](#blurhash-preview). |
- One file is emitted per output format and per size. `output: ["webp", "jpeg"]` with `sizes: [480, 800]` gives four files per image.
- Sizes wider than the source are dropped and replaced by the source width. An image is never enlarged.
@@ -264,6 +271,28 @@ Handles imports ending in `?image`.
- An image is encoded once and reused. The dev server reuses the file in `publicPath`. A build reuses its copy in the Vite cache directory.
- Editing an image or changing an option produces a new name, so a stale file is never served.
+#### BlurHash preview
+
+The default preview is a 20px image inlined as a data URL. A [BlurHash](https://blurha.sh) is a string of about 30 characters that the browser decodes into a blur. Turn it on in the plugin:
+
+```bash
+npm i blurhash
+```
+
+```ts
+imagePlugin({
+ local: {
+ sizes: [480, 800, 1200],
+ placeholder: { type: "blurhash" },
+ },
+});
+```
+
+- `blurhash` is an optional peer dependency. Install it yourself. The plugin fails at startup with install steps when it is missing.
+- The number of components is picked per image from its aspect ratio, about 12 in total. The long side gets more, so portraits and landscapes keep even detail.
+- The server paints the average color of the image. The browser decodes the hash into a 32px wide canvas and paints it over that color.
+- Only apps that turn it on import `blurhash`. The component itself never does.
+
#### `options.remote`
Handles imports starting with `image:`.
@@ -272,12 +301,12 @@ Handles imports starting with `image:`.
| --- | --- | --- |
| `transformURL` | `(url: string) => MaybePromise<{ src, variants }>` | Maps the text after `image:` to a source and its variants. |
-`src` is `{ source, width, height }`, and may carry a `placeholder` of `{ url, color }`. `variants` is one `SolidImageVariant` or an array of them.
+`src` is `{ source, width, height }`, and may carry a `placeholder`. Return `{ url, color }` for an image preview, or `{ hash, color }` for a BlurHash. The plugin adds the decoder for a hash. `variants` is one `SolidImageVariant` or an array of them.
## How it works
1. `SolidImage` renders a padding based aspect ratio box, so the layout is stable before the image arrives.
-2. The box is painted with the inline preview and the dominant color, when the source carries a placeholder. The preview is a few pixels wide, so the browser scales it up into a blur.
+2. The box is painted with the preview and its color, when the source carries a placeholder. An image preview is a few pixels wide, so the browser scales it up into a blur. A BlurHash is decoded in the browser, and the server paints its average color until then.
3. An `IntersectionObserver` watches the container. Nothing loads until it enters the viewport.
4. Once visible, the `
` and your placeholder render. The image starts transparent.
5. Your placeholder calls `onLoad` to say it is on screen.
diff --git a/package.json b/package.json
index fc53930..d05ce1d 100644
--- a/package.json
+++ b/package.json
@@ -34,15 +34,22 @@
"sharp": "^0.35.3"
},
"peerDependencies": {
+ "blurhash": "^2.0.5",
"solid-js": "^1.9.9",
"vite": "^8 || ^9"
},
+ "peerDependenciesMeta": {
+ "blurhash": {
+ "optional": true
+ }
+ },
"devDependencies": {
"@changesets/cli": "^2.30.0",
"@tsdown/css": "^0.22.12",
"@types/node": "^25.5.0",
"@vitest/browser": "4.1.10",
"@vitest/browser-playwright": "4.1.10",
+ "blurhash": "2.0.5",
"playwright": "^1.63.0",
"solid-js": "^1.9.9",
"tsdown": "^0.22.12",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fcfa8b7..0618b9f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -27,6 +27,9 @@ importers:
'@vitest/browser-playwright':
specifier: 4.1.10
version: 4.1.10(playwright@1.63.0)(vite@8.1.5(@types/node@25.9.5))(vitest@4.1.10)
+ blurhash:
+ specifier: 2.0.5
+ version: 2.0.5
playwright:
specifier: ^1.63.0
version: 1.63.0
@@ -1014,6 +1017,9 @@ packages:
resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==}
engines: {node: '>=4'}
+ blurhash@2.0.5:
+ resolution: {integrity: sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==}
+
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
@@ -2708,6 +2714,8 @@ snapshots:
dependencies:
is-windows: 1.0.2
+ blurhash@2.0.5: {}
+
braces@3.0.3:
dependencies:
fill-range: 7.1.1
diff --git a/src/__tests__/browser/solid-image.test.tsx b/src/__tests__/browser/solid-image.test.tsx
index d994614..c4a1d03 100644
--- a/src/__tests__/browser/solid-image.test.tsx
+++ b/src/__tests__/browser/solid-image.test.tsx
@@ -1,5 +1,6 @@
import { onMount, Show } from "solid-js";
import { render } from "solid-js/web";
+import { decode } from "blurhash";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SolidImage } from "../../core/index";
import "../../core/styles.css";
@@ -224,6 +225,51 @@ describe("SolidImage in the browser", () => {
expect(box.style.backgroundImage).toContain(PIXEL);
});
+ it("decodes a BlurHash into the preview and drops it once the image loads", async () => {
+ const hash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj";
+ const calls: [string, number, number][] = [];
+
+ const { host, scrollIntoView } = mount(() => (
+ {
+ calls.push([value, width, height]);
+ return decode(value, width, height);
+ },
+ },
+ }}
+ alt="pixel"
+ fallback={(visible, show) => (
+
+
+
+ )}
+ />
+ ));
+
+ const box = host.querySelector('[data-solid-image="aspect-ratio"]')!;
+
+ // The hash is decoded before the image is anywhere near the viewport.
+ await expect.poll(() => box.style.backgroundImage).toContain("data:image/png");
+ expect(box.style.backgroundColor).toBe("rgb(51, 102, 153)");
+ // Decoded small, at the aspect ratio of the image.
+ expect(calls).toEqual([[hash, 32, 18]]);
+
+ scrollIntoView();
+
+ await expect.poll(() => findImage(host)?.style.opacity).toBe("1");
+
+ expect(box.style.backgroundImage).toBe("");
+ expect(box.style.backgroundColor).toBe("");
+ });
+
it("reveals the image with no fallback at all", async () => {
const { host, scrollIntoView } = mount(() => (
diff --git a/src/__tests__/components.test.tsx b/src/__tests__/components.test.tsx
index baf90f5..bd50b49 100644
--- a/src/__tests__/components.test.tsx
+++ b/src/__tests__/components.test.tsx
@@ -1,6 +1,6 @@
import { createRoot } from "solid-js";
import { renderToString } from "solid-js/web";
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import { ClientOnly, createClientSignal } from "../core/client-only";
import { createLazyRender } from "../core/create-lazy-render";
import { SolidImage } from "../core/index";
@@ -199,6 +199,29 @@ describe("SolidImage SSR", () => {
expect(html).toContain("background-size:cover");
});
+ it("paints only the average color of a BlurHash on the server", () => {
+ const decode = vi.fn(() => new Uint8ClampedArray(4));
+
+ const html = renderToString(() => (
+ loading
}
+ />
+ ));
+
+ expect(html).toContain("background-color:#336699");
+ expect(html).not.toContain("background-image");
+ // Decoding needs a canvas, so the server never calls it.
+ expect(decode).not.toHaveBeenCalled();
+ });
+
it("renders no placeholder background when the source has none", () => {
const html = renderToString(() => (
{
@@ -85,3 +87,34 @@ describe("getEmptyImageURL", () => {
expect(decodeURIComponent(url)).toContain('height="600"');
});
});
+
+describe("getPlaceholderStyle", () => {
+ it("paints the preview image over its color", () => {
+ expect(getPlaceholderStyle({ color: "#336699", url: "data:image/webp;base64,AAA" })).toEqual({
+ "background-color": "#336699",
+ "background-image": 'url("data:image/webp;base64,AAA")',
+ "background-size": "cover",
+ "background-position": "center",
+ });
+ });
+
+ it("paints only the color while there is no image yet", () => {
+ expect(getPlaceholderStyle({ color: "#336699" })).toEqual({
+ "background-color": "#336699",
+ });
+ });
+});
+
+describe("isBlurhashPlaceholder", () => {
+ it("recognizes a BlurHash preview", () => {
+ const decode = () => new Uint8ClampedArray(4);
+
+ expect(isBlurhashPlaceholder({ hash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", color: "#fff", decode })).toBe(
+ true,
+ );
+ });
+
+ it("recognizes an inline image preview", () => {
+ expect(isBlurhashPlaceholder({ url: "data:image/webp;base64,AAA", color: "#fff" })).toBe(false);
+ });
+});
diff --git a/src/__tests__/vite-plugin.test.ts b/src/__tests__/vite-plugin.test.ts
index 10555bc..a7d1217 100644
--- a/src/__tests__/vite-plugin.test.ts
+++ b/src/__tests__/vite-plugin.test.ts
@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
+import { isBlurhashValid } from "blurhash";
import sharp from "sharp";
import type { Plugin } from "vite";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
@@ -32,6 +33,12 @@ function callConfigResolved(
fn.call({} as any, { command, cacheDir, publicDir } as any);
}
+function callBuildStart(plugin: Plugin) {
+ const hook = plugin.buildStart as any;
+ const fn = typeof hook === "function" ? hook : hook.handler;
+ return fn.call({} as any, {} as any);
+}
+
function getPlugin(plugins: Plugin[], name: string): Plugin {
const found = plugins.find(plugin => plugin.name === name);
if (!found) {
@@ -540,6 +547,147 @@ describe("local images", () => {
});
});
+describe("blurhash placeholder", () => {
+ // The example hash from the BlurHash project, at 4 by 3 components.
+ const EXAMPLE_HASH = "LEHV6nWB2yk8pyo0adR*.7kCMdnj";
+
+ let dir: string;
+
+ beforeAll(async () => {
+ dir = await fs.mkdtemp(path.join(os.tmpdir(), "solid-image-blurhash-"));
+ await sharp({ create: { width: 320, height: 180, channels: 3, background: "#336699" } })
+ .png()
+ .toFile(path.join(dir, "photo.png"));
+ });
+
+ afterAll(async () => {
+ await fs.rm(dir, { recursive: true, force: true });
+ });
+
+ function createPlugin(placeholder: NonNullable["placeholder"]) {
+ return getPlugin(
+ imagePlugin({
+ local: { sizes: [400], output: ["webp"], publicPath: path.join(dir, "public"), placeholder },
+ }),
+ "solid-start:image/local",
+ );
+ }
+
+ // The first character of a hash holds its component counts, in base 83.
+ function readComponents(hash: string): [number, number] {
+ const flag =
+ "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".indexOf(
+ hash[0]!,
+ );
+ return [(flag % 9) + 1, Math.floor(flag / 9) + 1];
+ }
+
+ function readPlaceholder(code: string) {
+ return JSON.parse(/placeholder: \{ \.\.\.(\{.+?\}), decode \}/.exec(code)![1]!);
+ }
+
+ it("ships a BlurHash and imports the decoder", async () => {
+ const code: string = await callLoad(
+ createPlugin({ type: "blurhash" }),
+ path.join(dir, "photo.png?image-source"),
+ );
+
+ expect(code).toContain('import { decode } from "blurhash";');
+
+ const placeholder = readPlaceholder(code);
+ expect(isBlurhashValid(placeholder.hash).result).toBe(true);
+ // The 320 by 180 fixture is 16:9, which gets 5 by 3 components.
+ expect(readComponents(placeholder.hash)).toEqual([5, 3]);
+ // A flat image averages to its own color.
+ expect(placeholder.color).toBe("#336699");
+ expect(placeholder.url).toBeUndefined();
+ });
+
+ it("picks the components per image from its aspect ratio", async () => {
+ await sharp({ create: { width: 180, height: 320, channels: 3, background: "#336699" } })
+ .png()
+ .toFile(path.join(dir, "portrait.png"));
+ await sharp({ create: { width: 400, height: 40, channels: 3, background: "#336699" } })
+ .png()
+ .toFile(path.join(dir, "banner.png"));
+
+ const plugin = createPlugin({ type: "blurhash" });
+ const portrait: string = await callLoad(plugin, path.join(dir, "portrait.png?image-source"));
+ const banner: string = await callLoad(plugin, path.join(dir, "banner.png?image-source"));
+
+ expect(readComponents(readPlaceholder(portrait).hash)).toEqual([3, 5]);
+ expect(readComponents(readPlaceholder(banner).hash)).toEqual([9, 1]);
+ });
+
+ it("does not import blurhash for the default preview", async () => {
+ const code: string = await callLoad(
+ createPlugin(undefined),
+ path.join(dir, "photo.png?image-source"),
+ );
+
+ expect(code).not.toContain("blurhash");
+ });
+
+ it("loads blurhash when the build starts", async () => {
+ await expect(callBuildStart(createPlugin({ type: "blurhash" }))).resolves.toBeUndefined();
+ });
+
+ it("explains how to install blurhash when it is missing", async () => {
+ vi.doMock("blurhash", () => {
+ throw new Error("Cannot find package 'blurhash'");
+ });
+
+ try {
+ await expect(callBuildStart(createPlugin({ type: "blurhash" }))).rejects.toThrow(
+ 'The BlurHash placeholder needs the "blurhash" package. Install it with `npm i blurhash`.',
+ );
+ } finally {
+ vi.doUnmock("blurhash");
+ }
+ });
+
+ it("imports the decoder for a remote BlurHash", async () => {
+ const plugin = getPlugin(
+ imagePlugin({
+ remote: {
+ transformURL: () => ({
+ src: {
+ source: "/a.jpg",
+ width: 4,
+ height: 3,
+ placeholder: { hash: EXAMPLE_HASH, color: "#336699" },
+ },
+ variants: [],
+ }),
+ },
+ }),
+ "solid-start:image/remote",
+ );
+
+ const code: string = await callLoad(plugin, "image:a");
+
+ expect(code).toContain('import { decode } from "blurhash";');
+ expect(code).toContain("placeholder: { ...SRC.placeholder, decode }");
+ expect(code).toContain(EXAMPLE_HASH);
+ });
+
+ it("does not import blurhash for a remote image without a hash", async () => {
+ const plugin = getPlugin(
+ imagePlugin({
+ remote: {
+ transformURL: () => ({
+ src: { source: "/a.jpg", width: 4, height: 3 },
+ variants: [],
+ }),
+ },
+ }),
+ "solid-start:image/remote",
+ );
+
+ expect(await callLoad(plugin, "image:a")).not.toContain("blurhash");
+ });
+});
+
describe("getEffectiveSizes", () => {
it("keeps every size that fits inside the source", () => {
expect(getEffectiveSizes([400, 800], 1200)).toEqual([400, 800]);
diff --git a/src/__tests__/vite-transformers.test.ts b/src/__tests__/vite-transformers.test.ts
index cddfe74..1e5ec69 100644
--- a/src/__tests__/vite-transformers.test.ts
+++ b/src/__tests__/vite-transformers.test.ts
@@ -3,7 +3,13 @@ import os from "node:os";
import path from "node:path";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
-import { getImageData, transformImage } from "../vite/transformers";
+import { encode, isBlurhashValid } from "blurhash";
+import {
+ getBlurhashComponents,
+ getBlurhashData,
+ getImageData,
+ transformImage,
+} from "../vite/transformers";
let dir: string;
let imagePath: string;
@@ -100,6 +106,88 @@ describe("transformImage", () => {
});
});
+// BlurHash writes every value in base 83 with this alphabet.
+const BASE83 =
+ "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~";
+
+function readBase83(value: string) {
+ return [...value].reduce((total, character) => total * 83 + BASE83.indexOf(character), 0);
+}
+
+// The first character of a hash holds its component counts.
+function readComponents(hash: string): [number, number] {
+ const flag = readBase83(hash[0]!);
+ return [(flag % 9) + 1, Math.floor(flag / 9) + 1];
+}
+
+describe("getBlurhashComponents", () => {
+ it.each([
+ ["4:3 landscape", 4, 3, [4, 3]],
+ ["16:9", 16, 9, [5, 3]],
+ ["square", 1, 1, [3, 3]],
+ ["9:16 portrait", 9, 16, [3, 5]],
+ ["3:1 panorama", 3, 1, [6, 2]],
+ ["10:1 banner", 10, 1, [9, 1]],
+ ["1:10 strip", 1, 10, [1, 9]],
+ ])("splits the components for a %s image", (_name, width, height, expected) => {
+ expect(getBlurhashComponents(width as number, height as number)).toEqual(expected);
+ });
+
+ it("keeps every count within the 1 to 9 BlurHash allows", () => {
+ expect(getBlurhashComponents(1000, 1)).toEqual([9, 1]);
+ expect(getBlurhashComponents(1, 1000)).toEqual([1, 9]);
+ });
+
+ it("falls back to 4 by 3 when the size is unknown", () => {
+ expect(getBlurhashComponents(0, 0)).toEqual([4, 3]);
+ });
+});
+
+describe("getBlurhashData", () => {
+ it("encodes a valid BlurHash with components picked from the aspect ratio", async () => {
+ const { hash } = await getBlurhashData(imagePath, encode);
+
+ expect(isBlurhashValid(hash).result).toBe(true);
+ // The 800 by 400 source has a ratio of 2.
+ expect(readComponents(hash)).toEqual([5, 2]);
+ });
+
+ it("reports the average color, which is the base color of the hash", async () => {
+ const { hash, color } = await getBlurhashData(imagePath, encode);
+
+ expect(color).toBe("#112233");
+ // Characters 2 to 5 hold the base color as a 24 bit number.
+ expect(readBase83(hash.slice(2, 6))).toBe(0x112233);
+ });
+
+ it("encodes a rotated photo upright", async () => {
+ const seen: [number, number][] = [];
+
+ const { hash } = await getBlurhashData(rotatedPath, (pixels, width, height, x, y) => {
+ seen.push([width, height]);
+ return encode(pixels, width, height, x, y);
+ });
+
+ // Stored as 64 by 32 with orientation 6, so it displays as 32 by 64.
+ expect(seen).toEqual([[16, 32]]);
+ // A portrait gets more vertical components.
+ expect(readComponents(hash)).toEqual([2, 5]);
+ });
+
+ it("encodes a small copy instead of every pixel", async () => {
+ const seen: [number, number, number, number][] = [];
+
+ await getBlurhashData(imagePath, (pixels, width, height, x, y) => {
+ seen.push([width, height, x, y]);
+ expect(pixels.length).toBe(width * height * 4);
+ return encode(pixels, width, height, x, y);
+ });
+
+ // The 800 by 400 source is reduced to fit inside 32 pixels.
+ expect(seen).toEqual([[32, 16, 5, 2]]);
+ });
+});
+
describe("transformImage output", () => {
it("never enlarges an image past its own width", async () => {
const buffer = await transformImage(imagePath, "webp", 1600, 80).toBuffer();
diff --git a/src/core/index.tsx b/src/core/index.tsx
index 806c8be..fa7c921 100644
--- a/src/core/index.tsx
+++ b/src/core/index.tsx
@@ -1,5 +1,5 @@
import type { JSX } from "solid-js";
-import { createMemo, createSignal, For, Show } from "solid-js";
+import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
import { ClientOnly } from "./client-only.tsx";
import { createLazyRender } from "./create-lazy-render.ts";
import {
@@ -8,10 +8,20 @@ import {
mergeImageVariantsToSrcSet,
} from "./transformer.ts";
import type { SolidImageSource, SolidImageTransformer } from "./types.ts";
-import { getAspectRatioBoxStyle, getEmptyImageURL, getPlaceholderStyle } from "./utils.ts";
+import {
+ getAspectRatioBoxStyle,
+ getBlurhashURL,
+ getEmptyImageURL,
+ getPlaceholderStyle,
+ isBlurhashPlaceholder,
+} from "./utils.ts";
import "./styles.css";
+// Width a BlurHash is decoded at. The browser scales it up, and a blur needs
+// few pixels, so a small canvas decodes fast and looks the same.
+const BLURHASH_WIDTH = 32;
+
export interface SolidImageProps {
/** The image, its intrinsic size and any options the transformer needs. */
src: SolidImageSource;
@@ -126,6 +136,21 @@ export function SolidImage(props: SolidImageProps): JSX.Element {
}),
);
+ // Decoding a BlurHash needs a canvas. Effects only run in the browser, so the
+ // server paints the average color and the blur follows once decoded.
+ const [blurhashURL, setBlurhashURL] = createSignal();
+ createEffect(() => {
+ const placeholder = props.src.placeholder;
+ if (!placeholder || !isBlurhashPlaceholder(placeholder)) {
+ setBlurhashURL(undefined);
+ return;
+ }
+
+ const ratio = width() > 0 ? height() / width() : 1;
+ const decodedHeight = Math.max(1, Math.round(BLURHASH_WIDTH * ratio));
+ setBlurhashURL(getBlurhashURL(placeholder, BLURHASH_WIDTH, decodedHeight));
+ });
+
const boxStyle = createMemo(() => {
const style = getAspectRatioBoxStyle({
width: width(),
@@ -139,7 +164,8 @@ export function SolidImage(props: SolidImageProps): JSX.Element {
return style;
}
- return { ...style, ...getPlaceholderStyle(placeholder) };
+ const url = isBlurhashPlaceholder(placeholder) ? blurhashURL() : placeholder.url;
+ return { ...style, ...getPlaceholderStyle({ color: placeholder.color, url }) };
});
return (
diff --git a/src/core/types.ts b/src/core/types.ts
index 30e8afc..21a6c6c 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -46,6 +46,19 @@ export interface SolidImagePlaceholder {
color: string;
}
+/**
+ * A BlurHash preview of an image.
+ * The hash is a short string that the browser decodes into a blurred image.
+ */
+export interface SolidImageBlurhashPlaceholder {
+ /** The encoded BlurHash. */
+ hash: string;
+ /** Average color of the image, as a hex string. It is painted until the hash is decoded. */
+ color: string;
+ /** Decodes the hash into RGBA pixels. This is `decode` from the `blurhash` package. */
+ decode: (hash: string, width: number, height: number) => Uint8ClampedArray;
+}
+
/**
* An image source
*/
@@ -55,7 +68,7 @@ export interface SolidImageSource {
height: number;
options: T;
/** Inline preview shown until the image has loaded. */
- placeholder?: SolidImagePlaceholder;
+ placeholder?: SolidImagePlaceholder | SolidImageBlurhashPlaceholder;
}
/**
diff --git a/src/core/utils.ts b/src/core/utils.ts
index 16ab909..b5804b3 100644
--- a/src/core/utils.ts
+++ b/src/core/utils.ts
@@ -1,6 +1,6 @@
import type { JSX } from "solid-js";
import type { AspectRatio } from "./aspect-ratio";
-import type { SolidImagePlaceholder } from "./types";
+import type { SolidImageBlurhashPlaceholder, SolidImagePlaceholder } from "./types";
function kebabify(str: string): string {
return str
@@ -40,16 +40,57 @@ export function getAspectRatioBoxStyle(ratio: AspectRatio): JSX.CSSProperties {
}
/**
- * Style that paints the inline preview behind the image.
+ * Style that paints the preview behind the image.
* The preview is a few pixels wide, so the browser scales it up and blurs it.
+ * Without a URL only the color is painted.
*/
-export function getPlaceholderStyle(placeholder: SolidImagePlaceholder): JSX.CSSProperties {
- return {
+export function getPlaceholderStyle(placeholder: {
+ color: string;
+ url?: string | undefined;
+}): JSX.CSSProperties {
+ const style: JSX.CSSProperties = {
"background-color": placeholder.color,
- "background-image": `url("${placeholder.url}")`,
- "background-size": "cover",
- "background-position": "center",
};
+
+ if (placeholder.url) {
+ style["background-image"] = `url("${placeholder.url}")`;
+ style["background-size"] = "cover";
+ style["background-position"] = "center";
+ }
+
+ return style;
+}
+
+/** Tells a BlurHash preview apart from an inline image preview. */
+export function isBlurhashPlaceholder(
+ placeholder: SolidImagePlaceholder | SolidImageBlurhashPlaceholder,
+): placeholder is SolidImageBlurhashPlaceholder {
+ return "hash" in placeholder;
+}
+
+/**
+ * Decodes a BlurHash into a PNG data URL of the given size.
+ * It draws on a canvas, so call it in the browser only.
+ */
+export function getBlurhashURL(
+ placeholder: SolidImageBlurhashPlaceholder,
+ width: number,
+ height: number,
+): string | undefined {
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+
+ const context = canvas.getContext("2d");
+ if (!context) {
+ return undefined;
+ }
+
+ const image = context.createImageData(width, height);
+ image.data.set(placeholder.decode(placeholder.hash, width, height));
+ context.putImageData(image, 0, 0);
+
+ return canvas.toDataURL();
}
/** Returns an empty SVG of the given size. */
diff --git a/src/vite/index.ts b/src/vite/index.ts
index a535af3..acc4979 100644
--- a/src/vite/index.ts
+++ b/src/vite/index.ts
@@ -3,13 +3,19 @@ import path from "node:path";
import type { Plugin } from "vite";
import { getFilesFromFormat, getMIMEFromFormat, getOutputFileFromFormat } from "../core/transformer.ts";
import type {
+ SolidImageBlurhashPlaceholder,
SolidImageFile,
SolidImageFormat,
SolidImagePlaceholder,
SolidImageVariant,
} from "../core/types.ts";
import { fileExists, getFileSignature, outputFile } from "./fs.ts";
-import { getImageData, getPlaceholderData, transformImage } from "./transformers.ts";
+import {
+ getBlurhashData,
+ getImageData,
+ getPlaceholderData,
+ transformImage,
+} from "./transformers.ts";
import xxHash32 from "./xxhash32.ts";
const DEFAULT_INPUT: SolidImageFormat[] = ["png", "jpeg", "webp"];
@@ -22,6 +28,14 @@ const DEFAULT_PLACEHOLDER_SIZE = 20;
type MaybePromise = T | Promise;
+/**
+ * Turns on a BlurHash preview instead of the inline image preview.
+ * The number of components is picked per image from its aspect ratio.
+ */
+export interface BlurhashPlaceholderOptions {
+ type: "blurhash";
+}
+
export interface SolidImageOptions {
/** Handles imports that end with `?image`. */
local?: {
@@ -36,10 +50,14 @@ export interface SolidImageOptions {
/** Directory the dev server writes processed files to. Defaults to Vite's `publicDir`. */
publicPath?: string;
/**
- * Inline preview shown until the image has loaded.
- * Set to `false` to skip it, or give a width in pixels. Defaults to 20.
+ * Preview shown until the image has loaded. Defaults to a 20px inline image.
+ *
+ * - Set to `false` to skip it.
+ * - Give `{ size }` to change the width of the inline image.
+ * - Give `{ type: "blurhash" }` to use a BlurHash instead. It needs the
+ * `blurhash` package installed.
*/
- placeholder?: boolean | { size?: number };
+ placeholder?: boolean | { type?: "image"; size?: number } | BlurhashPlaceholderOptions;
};
/** Handles imports that start with `image:`. */
remote?: {
@@ -49,7 +67,7 @@ export interface SolidImageOptions {
source: string;
width: number;
height: number;
- placeholder?: SolidImagePlaceholder;
+ placeholder?: SolidImagePlaceholder | Omit;
};
variants: SolidImageVariant | SolidImageVariant[];
}>;
@@ -70,6 +88,58 @@ function isValidFileExtension(extensions: Set, target: string): target i
return extensions.has(target);
}
+type ResolvedPlaceholder =
+ | { type: "none" }
+ | { type: "image"; size: number }
+ | { type: "blurhash" };
+
+function resolvePlaceholder(
+ option: NonNullable["placeholder"],
+): ResolvedPlaceholder {
+ if (option === false) {
+ return { type: "none" };
+ }
+ if (option === undefined || option === true) {
+ return { type: "image", size: DEFAULT_PLACEHOLDER_SIZE };
+ }
+ if (option.type === "blurhash") {
+ return { type: "blurhash" };
+ }
+ return { type: "image", size: option.size ?? DEFAULT_PLACEHOLDER_SIZE };
+}
+
+/**
+ * Loads the `blurhash` package.
+ * It is only needed for the BlurHash preview, so it is an optional peer
+ * dependency that the app installs itself.
+ */
+async function loadBlurhash(): Promise {
+ try {
+ return await import("blurhash");
+ } catch (error) {
+ throw new Error(
+ 'The BlurHash placeholder needs the "blurhash" package. Install it with `npm i blurhash`.',
+ { cause: error },
+ );
+ }
+}
+
+async function getPlaceholder(
+ imagePath: string,
+ placeholder: ResolvedPlaceholder,
+): Promise | undefined> {
+ switch (placeholder.type) {
+ case "none":
+ return undefined;
+ case "image":
+ return await getPlaceholderData(imagePath, placeholder.size);
+ case "blurhash": {
+ const { encode } = await loadBlurhash();
+ return await getBlurhashData(imagePath, encode);
+ }
+ }
+}
+
/**
* Returns the widths to emit for a source of the given width.
*
@@ -101,21 +171,25 @@ async function getImageSource(
relativePath: string,
fallback: SolidImageFormat,
sizes: number[],
- placeholderSize: number | false,
+ placeholder: ResolvedPlaceholder,
): Promise {
- const [imageData, placeholder] = await Promise.all([
+ const [imageData, preview] = await Promise.all([
getImageData(imagePath),
- placeholderSize === false ? undefined : getPlaceholderData(imagePath, placeholderSize),
+ getPlaceholder(imagePath, placeholder),
]);
const largestSize = Math.max(...getEffectiveSizes(sizes, imageData.width));
+ // A BlurHash is decoded in the browser. The module brings the decoder along,
+ // so only apps that turned the BlurHash preview on import the package.
+ const isBlurhash = placeholder.type === "blurhash";
const variantPath = `${relativePath}?image-raw-${fallback}-${largestSize}`;
return `
import source from ${JSON.stringify(variantPath)};
+${isBlurhash ? 'import { decode } from "blurhash";' : ""}
export default {
width: ${JSON.stringify(imageData.width)},
height: ${JSON.stringify(imageData.height)},
- placeholder: ${JSON.stringify(placeholder)},
+ placeholder: ${isBlurhash ? `{ ...${JSON.stringify(preview)}, decode }` : JSON.stringify(preview)},
source,
};
`;
@@ -187,9 +261,13 @@ export const imagePlugin = (options: SolidImageOptions) => {
const result = await transformUrl(param);
- return `const VARIANTS = ${JSON.stringify(result.variants)};
+ const remotePlaceholder = result.src.placeholder;
+ const isBlurhash = remotePlaceholder != null && "hash" in remotePlaceholder;
+
+ return `${isBlurhash ? 'import { decode } from "blurhash";\n' : ""}const SRC = ${JSON.stringify(result.src)};
+const VARIANTS = ${JSON.stringify(result.variants)};
export default {
- src: ${JSON.stringify(result.src)},
+ src: ${isBlurhash ? "{ ...SRC, placeholder: { ...SRC.placeholder, decode } }" : "SRC"},
transformer: {
transform() {
return VARIANTS;
@@ -209,13 +287,7 @@ export default {
const publicPathOption = options.local.publicPath;
// Replaced by Vite's public directory once the config is resolved.
let publicPath = publicPathOption ?? "public";
- const placeholder = options.local.placeholder ?? true;
- const placeholderSize =
- placeholder === false
- ? false
- : placeholder === true
- ? DEFAULT_PLACEHOLDER_SIZE
- : (placeholder.size ?? DEFAULT_PLACEHOLDER_SIZE);
+ const placeholder = resolvePlaceholder(options.local.placeholder);
// The last output format is the least preferred one, so it is the format
// every browser is expected to read.
const fallbackFormat = outputFormat[outputFormat.length - 1]!;
@@ -229,6 +301,11 @@ export default {
plugins.push({
name: "solid-start:image/local",
enforce: "pre",
+ async buildStart() {
+ if (placeholder.type === "blurhash") {
+ await loadBlurhash();
+ }
+ },
configResolved(config) {
isBuild = config.command === "build";
if (config.cacheDir) {
@@ -268,7 +345,7 @@ export default {
relativePath,
fallbackFormat,
sizes,
- placeholderSize,
+ placeholder,
);
}
// Get the transformer file
diff --git a/src/vite/transformers.ts b/src/vite/transformers.ts
index 0412321..c2133f0 100644
--- a/src/vite/transformers.ts
+++ b/src/vite/transformers.ts
@@ -70,6 +70,84 @@ export async function getPlaceholderData(
};
}
+export interface BlurhashData {
+ hash: string;
+ color: string;
+}
+
+/** Signature of `encode` from the `blurhash` package. */
+export type BlurhashEncode = (
+ pixels: Uint8ClampedArray,
+ width: number,
+ height: number,
+ componentX: number,
+ componentY: number,
+) => string;
+
+// Longest side the image is reduced to before it is encoded. A BlurHash keeps
+// only a few components, so more pixels cost time and add no detail.
+const BLURHASH_SAMPLE_SIZE = 32;
+
+// Total components a hash aims for. 4 by 3 is the usual BlurHash default, and
+// staying near that total keeps hashes near 28 characters.
+const BLURHASH_COMPONENT_BUDGET = 12;
+
+/**
+ * Picks the horizontal and vertical component counts for an image.
+ * The budget is split by aspect ratio, so the long side gets more components
+ * and detail stays even. Each count stays within the 1 to 9 BlurHash allows.
+ */
+export function getBlurhashComponents(width: number, height: number): [x: number, y: number] {
+ if (!(width > 0 && height > 0)) {
+ return [4, 3];
+ }
+
+ const ratio = width / height;
+ const clamp = (value: number) => Math.min(9, Math.max(1, Math.round(value)));
+
+ return [
+ clamp(Math.sqrt(BLURHASH_COMPONENT_BUDGET * ratio)),
+ clamp(Math.sqrt(BLURHASH_COMPONENT_BUDGET / ratio)),
+ ];
+}
+
+/**
+ * Encodes an image as a BlurHash, together with its average color.
+ * The component counts come from the aspect ratio of the image.
+ * The encoder is passed in because `blurhash` is an optional dependency.
+ */
+export async function getBlurhashData(
+ originalPath: string,
+ encode: BlurhashEncode,
+): Promise {
+ const { data, info } = await sharp(originalPath)
+ // Hash the photo as it displays, like the variants and the inline preview.
+ .autoOrient()
+ .resize(BLURHASH_SAMPLE_SIZE, BLURHASH_SAMPLE_SIZE, { fit: "inside", withoutEnlargement: true })
+ .ensureAlpha()
+ .raw()
+ .toBuffer({ resolveWithObject: true });
+
+ let red = 0;
+ let green = 0;
+ let blue = 0;
+ for (let i = 0; i < data.length; i += 4) {
+ red += data[i]!;
+ green += data[i + 1]!;
+ blue += data[i + 2]!;
+ }
+ const count = info.width * info.height;
+
+ const pixels = new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength);
+ // The sample keeps the aspect ratio of the image, so its size is enough.
+ const [componentX, componentY] = getBlurhashComponents(info.width, info.height);
+
+ return {
+ hash: encode(pixels, info.width, info.height, componentX, componentY),
+ color: `#${toHex(Math.round(red / count))}${toHex(Math.round(green / count))}${toHex(Math.round(blue / count))}`,
+ };
+}
+
interface ImageData {
width: number;
height: number;