Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/calm-owls-blur.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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:`.
Expand All @@ -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 `<img>` and your placeholder render. The image starts transparent.
5. Your placeholder calls `onLoad` to say it is on screen.
Expand Down
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 46 additions & 0 deletions src/__tests__/browser/solid-image.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(() => (
<SolidImage
src={{
source: PIXEL,
width: 1600,
height: 900,
options: {},
placeholder: {
hash,
color: "#336699",
decode: (value, width, height) => {
calls.push([value, width, height]);
return decode(value, width, height);
},
},
}}
alt="pixel"
fallback={(visible, show) => (
<Show when={visible()}>
<Placeholder show={show} />
</Show>
)}
/>
));

const box = host.querySelector<HTMLElement>('[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(() => (
<SolidImage src={{ source: PIXEL, width: 100, height: 100, options: {} }} alt="pixel" />
Expand Down
25 changes: 24 additions & 1 deletion src/__tests__/components.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(() => (
<SolidImage
src={{
source: "/hero.png",
width: 1600,
height: 900,
options: {},
placeholder: { hash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", color: "#336699", decode },
}}
alt="hero"
fallback={() => <div>loading</div>}
/>
));

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(() => (
<SolidImage
Expand Down
33 changes: 33 additions & 0 deletions src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
getEmptySVGPlaceholder,
getEncodedOptionalSVG,
getEncodedSVG,
getPlaceholderStyle,
isBlurhashPlaceholder,
} from "../core/utils";

describe("getAspectRatioBoxStyle", () => {
Expand Down Expand Up @@ -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);
});
});
Loading
Loading