Skip to content
Open
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/tidy-hashes-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@solidjs/image": minor
---

Add an opt-in ThumbHash preview. Set `placeholder: { type: "thumbhash" }` in the Vite plugin and install `thumbhash`, which is an optional peer dependency.

ThumbHash previews keep their binary hash as a `Uint8Array`, preserve alpha in the server-side average color, and work for both local and remote images.
43 changes: 37 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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.
- [`thumbhash`](https://github.com/evanw/thumbhash) 0.1.1 or newer, only for the ThumbHash preview. It is an optional peer dependency.

## Setup

Expand Down Expand Up @@ -228,6 +229,7 @@ interface SolidImageSource<T> {
width: number;
height: number;
options: T;
placeholder?: SolidImagePreview;
}

interface SolidImagePlaceholder {
Expand All @@ -241,6 +243,12 @@ interface SolidImageBlurhashPlaceholder {
decode: (hash: string, width: number, height: number) => Uint8ClampedArray;
}

interface SolidImageThumbhashPlaceholder {
hash: Uint8Array;
color: string;
decode: (hash: Uint8Array) => string;
}

interface SolidImageVariant {
path: string;
width: number;
Expand All @@ -252,8 +260,8 @@ interface SolidImageTransformer<T> {
}
```

- `SolidImageMIME` is `"image/avif" | "image/jpeg" | "image/png" | "image/webp" | "image/tiff"`.
- `SolidImageFormat` is `"avif" | "jpeg" | "png" | "webp" | "tiff"`.
- `SolidImageMIME` is `"image/avif" | "image/jpeg" | "image/png" | "image/webp" | "image/tiff" | "image/gif"`.
- `SolidImageFormat` is `"avif" | "jpeg" | "png" | "webp" | "tiff" | "gif"`.
- `SolidImageFile` is every file extension that maps to a format, such as `"jpg"`, `"jfif"` and `"tif"`.

Notes on the shape:
Expand Down Expand Up @@ -283,7 +291,7 @@ Handles imports ending in `?image`, and single file imports ending in `image-url
| `input` | `SolidImageFormat[]` | `["png", "jpeg", "webp", "gif"]` | Source formats to process. Other files are left alone. |
| `output` | `SolidImageFormat[]` | `["webp", "jpeg"]` | Formats to emit. They are offered smallest first, whatever the order here. |
| `publicPath` | `string` | Vite's `publicDir` | Directory the dev server writes processed files to. |
| `placeholder` | `boolean \| { size?: number } \| { type: "blurhash" }` | `true` | Preview shown while the image loads. See [BlurHash preview](#blurhash-preview). |
| `placeholder` | `boolean \| { size?: number } \| { type: "blurhash" } \| { type: "thumbhash" }` | `true` | Preview shown while the image loads. See the hash preview sections below. |
| `concurrency` | `number` | CPU cores | Most images processed at the same time. |

- One file is emitted per output format and per size. `output: ["webp", "jpeg"]` with `sizes: [480, 800]` gives four files per image.
Expand Down Expand Up @@ -328,6 +336,29 @@ imagePlugin({
- 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.

#### ThumbHash preview

[ThumbHash](https://github.com/evanw/thumbhash) stores a compact binary preview and can preserve transparency. Turn it on in the plugin:

```bash
npm i thumbhash
```

```ts
imagePlugin({
local: {
sizes: [480, 800, 1200],
placeholder: { type: "thumbhash" },
},
});
```

- `thumbhash` is an optional peer dependency. Install it yourself. The plugin fails at startup with install steps when it is missing.
- The plugin auto-orients the source and reduces it to fit inside 100 by 100 pixels before encoding, matching ThumbHash's input limit.
- The generated source keeps the hash as a `Uint8Array`; the disk cache only serializes its bytes as an array and restores the typed array in the generated module.
- The server paints ThumbHash's average RGBA color, including alpha. The browser decodes the hash with `thumbHashToDataURL` and paints the preview over that color.
- Only apps that turn it on import `thumbhash`. The component itself never does.

#### Single file URL

Some places take one file instead of a responsive image, such as an `og:image` tag, a CSS background or a canvas. Import the image with `?image-url` to get the URL of one file.
Expand All @@ -350,12 +381,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`. 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.
`src` is `{ source, width, height }`, and may carry a `placeholder`. Return `{ url, color }` for an image preview, `{ hash: string, color }` for a BlurHash, or `{ hash: Uint8Array, color }` for a ThumbHash. The plugin adds the matching decoder for either hash format. `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 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.
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. Hash previews are decoded in the browser; the server paints their average color until then.
3. An `IntersectionObserver` watches the container. Nothing loads until it comes within `rootMargin` of the viewport.
4. Once near, the `<img>` and your placeholder render. The image starts transparent.
5. Your placeholder calls `onLoad` to say it is on screen.
Expand All @@ -380,7 +411,7 @@ pnpm test:watch
pnpm changeset # add a changeset before opening a pull request
```

The [examples](./examples) folder has demo apps for the image and BlurHash previews.
The [examples](./examples) folder has demo apps for the image, BlurHash and ThumbHash previews.

The suite is split into two Vitest projects.

Expand Down
2 changes: 2 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Each folder is a small Vite app that uses `@solidjs/image` from this repository.

- [`lqip`](./lqip) shows a 20px copy of each image while it loads.
- [`blurhash`](./blurhash) shows a BlurHash of each image while it loads.
- [`thumbhash`](./thumbhash) shows a ThumbHash of each image while it loads.

## Run an example

Expand All @@ -19,6 +20,7 @@ Each folder is a small Vite app that uses `@solidjs/image` from this repository.
```bash
pnpm --filter @solidjs/image-example-lqip dev
pnpm --filter @solidjs/image-example-blurhash dev
pnpm --filter @solidjs/image-example-thumbhash dev
```

The examples use the built package. Run `pnpm build` again after you change `src`.
Expand Down
12 changes: 12 additions & 0 deletions examples/thumbhash/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ThumbHash example</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
21 changes: 21 additions & 0 deletions examples/thumbhash/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "@solidjs/image-example-thumbhash",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@solidjs/image": "workspace:*",
"solid-js": "^1.9.9",
"thumbhash": "^0.1.1"
},
"devDependencies": {
"typescript": "^7.0.0",
"vite": "^8.1.5",
"vite-plugin-solid": "^2.11.11"
}
}
83 changes: 83 additions & 0 deletions examples/thumbhash/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { SolidImage } from "@solidjs/image";
import { createSignal, For, onMount, Show } from "solid-js";

import fjord from "../../assets/fjord.jpg?image";
import highlands from "../../assets/highlands.jpg?image";
import sea from "../../assets/sea.jpg?image";
import strawberries from "../../assets/strawberries.jpg?image";
import valley from "../../assets/valley.jpg?image";

const PHOTOS = [
{ image: fjord, alt: "People on a cliff above a long fjord" },
{ image: sea, alt: "Evergreen trees above the sea, with mountains on the far shore" },
{ image: strawberries, alt: "Strawberries in green baskets" },
{ image: highlands, alt: "A narrow road below green cliffs in low cloud" },
{ image: valley, alt: "Granite cliffs above a river lined with pine trees" },
];

function Loading(props: { hold: boolean; show: () => void }) {
onMount(() => {
if (!props.hold) {
props.show();
}
});

return <span class="badge">{props.hold ? "Preview" : "Loading"}</span>;
}

function Gallery(props: { hold: boolean }) {
return (
<For each={PHOTOS}>
{(photo, index) => {
const placeholder = photo.image.src.placeholder;
const hash =
placeholder && "hash" in placeholder && placeholder.hash instanceof Uint8Array
? Array.from(placeholder.hash)
.map(value => value.toString(16).padStart(2, "0"))
.join("")
: "";

return (
<figure>
<SolidImage
{...photo.image}
alt={photo.alt}
eager={!props.hold && index() === 0}
sizes="(max-width: 832px) 100vw, 800px"
fallback={(visible, show) => (
<Show when={visible()}>
<Loading hold={props.hold} show={show} />
</Show>
)}
/>
<figcaption>
{photo.alt}. The ThumbHash is <code>{hash}</code>.
</figcaption>
</figure>
);
}}
</For>
);
}

export default function App() {
const [hold, setHold] = createSignal(false);

return (
<main>
<h1>ThumbHash preview</h1>
<p>
Each image carries a compact binary ThumbHash. The browser decodes it into a detailed,
color-accurate preview while the real image loads. ThumbHash also preserves alpha.
</p>
<p>Local images load fast. Keep the previews on screen to see them.</p>
<label>
<input type="checkbox" checked={hold()} onChange={event => setHold(event.currentTarget.checked)} />
Keep the previews on screen
</label>
<Show when={hold()} fallback={<Gallery hold={false} />}>
<Gallery hold />
</Show>
</main>
);
}
1 change: 1 addition & 0 deletions examples/thumbhash/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="@solidjs/image/env" />
6 changes: 6 additions & 0 deletions examples/thumbhash/src/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import "@solidjs/image/style.css";
import { render } from "solid-js/web";
import App from "./App";
import "./styles.css";

render(() => <App />, document.getElementById("app")!);
44 changes: 44 additions & 0 deletions examples/thumbhash/src/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
:root {
color-scheme: light dark;
font-family: system-ui, sans-serif;
line-height: 1.5;
}

body {
margin: 0;
}

main {
max-width: 800px;
margin: 0 auto;
padding: 32px 16px;
}

label {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 32px;
}

figure {
margin: 0 0 48px;
}

figcaption {
margin-top: 8px;
font-size: 14px;
opacity: 0.7;
overflow-wrap: anywhere;
}

.badge {
position: absolute;
top: 12px;
left: 12px;
padding: 2px 10px;
border-radius: 999px;
background: rgb(0 0 0 / 0.6);
color: white;
font-size: 13px;
}
15 changes: 15 additions & 0 deletions examples/thumbhash/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
17 changes: 17 additions & 0 deletions examples/thumbhash/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { imagePlugin } from "@solidjs/image/vite";
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";

export default defineConfig({
plugins: [
solid(),
imagePlugin({
local: {
sizes: [480, 800, 1200, 1600],
// A compact binary hash per image that the browser decodes into a preview.
// It needs the `thumbhash` package installed.
placeholder: { type: "thumbhash" },
},
}),
],
});
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,15 @@
"peerDependencies": {
"blurhash": "^2.0.5",
"solid-js": "^1.9.9",
"thumbhash": "^0.1.1",
"vite": "^8 || ^9"
},
"peerDependenciesMeta": {
"blurhash": {
"optional": true
},
"thumbhash": {
"optional": true
}
},
"devDependencies": {
Expand All @@ -52,6 +56,7 @@
"blurhash": "2.0.5",
"playwright": "^1.63.0",
"solid-js": "^1.9.9",
"thumbhash": "0.1.1",
"tsdown": "^0.22.12",
"typescript": "^7.0.0",
"vite": "^8.1.5",
Expand Down
Loading