Optimized image components and Vite tooling for Solid.
SolidImagerenders a responsive<picture>that reserves the aspect ratio, so the page does not shift while the image loads.- The image loads once it scrolls into view. Mark the image above the fold as
eagerand it loads right away. - A tiny preview of the image is inlined in the page and painted behind it, so there is something to look at from the first frame.
- Readers with no JavaScript still get the image.
- Your placeholder shows until the image is ready.
- The Vite plugin resizes and reformats local images at build time.
- Remote images go through your own URL mapping, so a CDN can serve the variants.
npm i @solidjs/imageRequirements:
solid-js1.9.9 or newer, and Vite 8 or newer. Both are peer dependencies.- Node 24 or newer for the Vite plugin. It uses
sharpto process images. blurhash2 or newer, only for the BlurHash preview. It is an optional peer dependency.
// vite.config.ts
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
import { imagePlugin } from "@solidjs/image/vite";
export default defineConfig({
plugins: [
solid(),
imagePlugin({
local: {
input: ["jpeg", "png"],
output: ["webp", "jpeg"],
sizes: [480, 800, 1200],
quality: 80,
publicPath: "public",
placeholder: { size: 20 },
},
}),
],
});imagePlugin returns an array of plugins. Spread it or nest it, Vite accepts both.
TypeScript does not know about imports such as ./photo.png?image and image:hero. Reference the shipped declarations once:
// env.d.ts
/// <reference types="@solidjs/image/env" />import "@solidjs/image/style.css";This positions the picture, the image and the placeholder inside the aspect ratio box. Import it once, in your app entry.
Import the image with the ?image query. You get the src and transformer props.
import { SolidImage } from "@solidjs/image";
import { onMount, Show } from "solid-js";
import example from "../images/example.jpg?image";
function Placeholder(props: { show: () => void }) {
onMount(() => props.show());
return <div>Loading...</div>;
}
export default function App() {
return (
<SolidImage
{...example}
alt="example"
fallback={(visible, show) => (
<Show when={visible()}>
<Placeholder show={show} />
</Show>
)}
/>
);
}Import image: followed by any string. The plugin passes that string to transformURL.
import example from "image:foobar";
<SolidImage {...example} alt="example" fallback={() => <div>Loading...</div>} />;imagePlugin({
remote: {
transformURL(url) {
return {
src: {
source: `https://cdn.example.com/${url}/1200.webp`,
width: 1200,
height: 900,
},
variants: [
{ path: `https://cdn.example.com/${url}/800.webp`, width: 800, type: "image/webp" },
{ path: `https://cdn.example.com/${url}/400.webp`, width: 400, type: "image/webp" },
],
};
},
},
});transformURL may be async, so it can call a CDN API.
The component works on its own. Pass src and an optional transformer:
<SolidImage
src={{ source: "/hero.jpg", width: 1600, height: 900, options: {} }}
alt="hero"
transformer={{
transform: source => [
{ path: `/cdn/${source.source}?w=400`, width: 400, type: "image/webp" },
{ path: `/cdn/${source.source}?w=800`, width: 800, type: "image/webp" },
],
}}
fallback={() => <div>Loading...</div>}
/>| Prop | Type | Required | Description |
|---|---|---|---|
src |
SolidImageSource<T> |
yes | The image, its intrinsic size and any options your transformer needs. |
alt |
string |
yes | Alternative text. |
fallback |
(visible: () => boolean, onLoad: () => void) => JSX.Element |
no | Placeholder shown while the image loads. |
transformer |
SolidImageTransformer<T> |
no | Produces the responsive variants for src. |
eager |
boolean |
no | Loads the image right away, preloads it from the head and gives it a high fetch priority. |
sizes |
string |
no | Value of the sizes attribute, such as 50vw. |
rootMargin |
string |
no | How far outside the viewport a lazy image starts loading, as a CSS margin. Defaults to 500px. |
onLoad |
() => void |
no | Called once the image has loaded and the placeholder is hidden. |
onError |
() => void |
no | Called when the image fails to load. |
errorFallback |
() => JSX.Element |
no | Shown when the image fails to load. |
crossOrigin |
JSX.HTMLCrossorigin |
no | Forwarded to the <img>. |
fetchPriority |
"high" | "low" | "auto" |
no | Forwarded to the <img>. Defaults to high for an eager image. |
decoding |
"sync" | "async" | "auto" |
no | Forwarded to the <img>. Defaults to async for a lazy image. |
The fallback callback takes two arguments.
visibleis a signal. It istruewhile the placeholder should be shown, andfalseonce the image has loaded.onLoadtells the component your placeholder is on screen. Call it once the placeholder has mounted. It can come before or after the image loads. The image is only revealed once both have happened, so an image that loads instantly never skips the placeholder.
The fallback renders on the client only, and only after the container scrolls into view. Leave it out and the image is revealed as soon as it loads.
Pass onError to hear about it, and errorFallback to show something in its place.
<SolidImage {...example} alt="example" errorFallback={() => <p>Could not load the image.</p>} fallback={...} />- The loading placeholder is removed, and the broken image stays hidden.
- The preview stays behind the error fallback.
errorFallbackrenders on the client only.
A lazy image starts loading once it is within 500px of the viewport, so it is often ready by the time it scrolls in. Change the distance with rootMargin, which takes a CSS margin such as 1000px or 50%.
<SolidImage {...example} alt="example" rootMargin="1000px" fallback={...} />The margin is read once, when the component is created.
Width descriptors do not tell the browser how wide the image will be on the page. It assumes the full viewport width and downloads a larger variant than it needs. Pass sizes whenever the image is not full width.
<SolidImage {...example} alt="example" sizes="(max-width: 600px) 100vw, 50vw" fallback={...} />Lazy loading costs time for the first image on the page, because nothing starts until the observer reports. Mark that one image as eager.
<SolidImage {...example} alt="example" eager fetchPriority="high" fallback={...} />The server then renders the real image instead of a blank placeholder, so the browser finds it while it parses the page. Leave every other image lazy.
An eager image is also preloaded with a <link rel="preload"> in the head, so the browser starts fetching it before it reaches the image. The link names the preferred format, and a browser that cannot read that format skips it. Solid adds the link when the server renders a page with a <head>.
interface SolidImageSource<T> {
source: string;
width: number;
height: number;
options: T;
}
interface SolidImagePlaceholder {
url: string;
color: string;
}
interface SolidImageBlurhashPlaceholder {
hash: string;
color: string;
decode: (hash: string, width: number, height: number) => Uint8ClampedArray;
}
interface SolidImageVariant {
path: string;
width: number;
type: SolidImageMIME;
}
interface SolidImageTransformer<T> {
transform: (source: SolidImageSource<T>) => SolidImageVariant | SolidImageVariant[];
}SolidImageMIMEis"image/avif" | "image/jpeg" | "image/png" | "image/webp" | "image/tiff".SolidImageFormatis"avif" | "jpeg" | "png" | "webp" | "tiff".SolidImageFileis every file extension that maps to a format, such as"jpg","jfif"and"tif".
Notes on the shape:
widthandheightare the intrinsic pixel size. They only reserve the aspect ratio box, so any pair with the right ratio works.- Variants are grouped by
type, and each group becomes one<source>with a mergedsrcset. - The browser takes the first
<source>it supports, so order your output formats from most to least preferred. - The
<img>carries the last group as its ownsrcset, for a browser that supports none of the formats above it. Make that group the most widely supported format. - Without a transformer no
<source>is rendered, and the browser loadssrc.source.
import { imagePlugin } from "@solidjs/image/vite";Both option groups are optional. Passing neither returns no plugin.
Handles imports ending in ?image, and single file imports ending in image-url.
| Option | Type | Default | Description |
|---|---|---|---|
sizes |
number[] |
required | Output widths in pixels. Height follows the aspect ratio. |
quality |
number | { [format]: number } |
50 for AVIF, 80 for the rest | Quality passed to sharp, from 1 to 100. A number applies to every format. PNG and GIF ignore it. |
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. |
concurrency |
number |
CPU cores | Most images processed at the same time. |
- One file is emitted per output format and per size.
output: ["webp", "jpeg"]withsizes: [480, 800]gives four files per image. - Formats are offered in this order: AVIF, WebP, TIFF, JPEG, PNG. The browser takes the first one it reads, and the
<img>falls back to the last. - A transparent image gets PNG in place of JPEG, since JPEG would paint the transparent pixels black.
- An opaque image drops PNG when JPEG is also listed, since JPEG is far smaller for photos. List PNG without JPEG to keep it.
- Sizes wider than the source are dropped and replaced by the source width. An image is never enlarged.
- Photos are turned upright using their EXIF orientation.
- Animated images keep every frame in WebP and GIF. Other formats keep the first frame. Animated GIFs are processed by default, and usually come out much smaller as WebP.
- JPEG uses mozjpeg and WebP uses its highest effort. PNG is lossless, so
qualitydoes not apply to it. - On build the files go through the bundler as assets, so
base,assetsDirand the build manifest apply to them. Nothing is written topublicPath. - On the dev server the files are written to
<publicPath>/.image/i-<hash>-<width>.<ext>and served from/.image/.... publicPathdefaults to Vite's public directory, which the dev server serves at the root of the site. Add.imageto.gitignore.- The
<img>falls back to the largest size of the last output format. The original file is never imported, so it does not reach the bundle. - The hash covers the content of the source file, the format, the width and the quality. It leaves out the path and the modification time, so a fresh checkout in CI still hits the cache.
- 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. - Previews are cached the same way, so a build or a dev server restart does not compute them again.
- The cache key also carries a pipeline version. A plugin update that changes how images are encoded writes new files instead of reusing old ones.
- Cached files unused for a week are removed when the dev server or a build starts.
- Every variant of an image shares one read of the file and its metadata within a build.
- Editing an image or changing an option produces a new name, so a stale file is never served.
The default preview is a 20px image inlined as a data URL. A BlurHash is a string of about 30 characters that the browser decodes into a blur. Turn it on in the plugin:
npm i blurhashimagePlugin({
local: {
sizes: [480, 800, 1200],
placeholder: { type: "blurhash" },
},
});blurhashis 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.
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.
import url from "./photo.jpg?image-url";
import thumbnail from "./photo.jpg?width=400&format=webp&image-url";widthdefaults to the largest ofsizes. The file is never wider than the source.formatdefaults to the format the<img>falls back to.- The file goes through the same pipeline and cache as the other variants.
- Put
image-urllast, so the shipped types match the import.
Handles imports starting with image:.
| Option | Type | Description |
|---|---|---|
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.
SolidImagerenders a padding based aspect ratio box, so the layout is stable before the image arrives.- 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.
- An
IntersectionObserverwatches the container. Nothing loads until it comes withinrootMarginof the viewport. - Once near, the
<img>and your placeholder render. The image starts transparent. - Your placeholder calls
onLoadto say it is on screen. - The image loads, and is decoded before it is shown, so a large image does not stall the fade.
- Once both steps are done, in either order, the placeholder is hidden, the image fades in over the preview, and the
onLoadprop fires. The fade is skipped for readers who ask for reduced motion. - If the image fails, the placeholder is removed,
onErrorfires, anderrorFallbackrenders over the preview. - On the server a lazy
<img>carries a blank SVG of the same size, so nothing is fetched before the image is in view. An eager<img>renders in full. The placeholder and the loading logic are client only. - The server also renders a
<noscript>copy of the image, so a reader with no JavaScript sees it. Browsers never load the content of a<noscript>element, so it costs nothing otherwise.
Every rendered element carries a data-solid-image attribute you can style. The values are container, aspect-ratio, picture, image and blocker. The shipped stylesheet uses the same attribute.
pnpm install
pnpm exec playwright install chromium # once, for the browser tests
pnpm build # bundle with tsdown
pnpm test # run every test once
pnpm test:node # server rendering and Vite plugin only
pnpm test:browser # browser tests only
pnpm test:watch
pnpm changeset # add a changeset before opening a pull requestThe examples folder has demo apps for the image and BlurHash previews.
The suite is split into two Vitest projects.
nodecovers server rendering throughrenderToString. It also calls the Vite plugin hooks directly, with real images processed by sharp.browserruns in headless Chromium through Vitest browser mode. It covers the client path, where a realIntersectionObserverdecides when the image loads.
MIT