Astro
@ditherkit/react works in client islands. For build-time dithering, use @ditherkit/core inside an Astro integration.
Status: ⚠️ Workaround. Two patterns, each covering a different use case. An example app is backlogged (
apps/example-astro).
Astro is an interesting case because it has two execution models — static HTML generation at build time, and React/Vue/Svelte "islands" that hydrate at runtime — and the right dithering approach is different for each.
What works
@ditherkit/reactin a client island — use<DitheredImage />inside an Astro React island for interactive, client-side dithering. Works out of the box because Astro's islands are regular React.@ditherkit/coreat build time — call@ditherkit/coredirectly from an Astro integration hook, or from a standalone build script that runs beforeastro build. This produces static dithered outputs that ship with your static site.
What doesn't work (yet)
- No
<DitheredImageSSR />in Astro. Astro's SSR is its own thing and@ditherkit/nextis Next.js-specific. The build-time pattern covers most use cases, but if you need on-demand server-side dithering in Astro, you'd write a custom endpoint. - No integration package. An
astro-ditherintegration that wires up build-time processing automatically would be ideal but hasn't been written.
Pattern 1 — Client island with @ditherkit/react
Good for: interactive previews, user-selected algorithms, live sliders.
pnpm add @ditherkit/reactCreate a React component in your Astro project:
// src/components/DitherPreview.tsx
import { DitheredImage, type DitherAlgorithm } from '@ditherkit/react'
import { useState } from 'react'
export function DitherPreview({ src, width, height }: {
src: string
width: number
height: number
}) {
const [algorithm, setAlgorithm] = useState<DitherAlgorithm>('Atkinson')
return (
<div>
<DitheredImage src={src} alt="" width={width} height={height} algorithm={algorithm} />
<select value={algorithm} onChange={(e) => setAlgorithm(e.target.value as DitherAlgorithm)}>
<option value="Floyd-Steinberg">Floyd-Steinberg</option>
<option value="Atkinson">Atkinson</option>
<option value="Threshold">Threshold</option>
</select>
</div>
)
}Use it in an Astro page as a client island:
---
// src/pages/index.astro
import { DitherPreview } from '../components/DitherPreview'
---
<html>
<body>
<DitherPreview
client:load
src="/portrait.jpg"
width={400}
height={600}
/>
</body>
</html>client:load tells Astro to hydrate the component on page load.
Use client:visible if it's below the fold. Everything else is
identical to a normal React SPA — see
React SPA for more.
Pattern 2 — Build-time dithering with @ditherkit/core
Good for: static marketing sites, blog post headers, logos, anything that should be pre-dithered and served as a regular static image with no runtime cost.
pnpm add @ditherkit/core sharpWrite a pre-build script:
// scripts/pre-dither.ts
import sharp from 'sharp'
import {
floydSteinbergDither,
grayscale,
type Color,
} from '@ditherkit/core'
import { readdir, writeFile } from 'node:fs/promises'
import { join, parse } from 'node:path'
const palette: Color[] = [
{ r: 0, g: 0, b: 0 },
{ r: 255, g: 255, b: 255 },
]
const sourceDir = 'src/content/images'
const outputDir = 'public/dithered'
for (const file of await readdir(sourceDir)) {
const { name } = parse(file)
const sourceBuffer = await sharp(join(sourceDir, file))
.resize(800)
.raw()
.toBuffer({ resolveWithObject: true })
// RGB → RGBA
const pixels = new Uint8ClampedArray(sourceBuffer.info.width * sourceBuffer.info.height * 4)
for (let i = 0, j = 0; i < sourceBuffer.data.length; i += 3, j += 4) {
pixels[j] = sourceBuffer.data[i]
pixels[j + 1] = sourceBuffer.data[i + 1]
pixels[j + 2] = sourceBuffer.data[i + 2]
pixels[j + 3] = 255
}
grayscale(pixels)
floydSteinbergDither(pixels, sourceBuffer.info.width, sourceBuffer.info.height, palette)
// RGBA → RGB
const rgbOut = new Uint8Array((pixels.length / 4) * 3)
for (let i = 0, j = 0; i < pixels.length; i += 4, j += 3) {
rgbOut[j] = pixels[i]
rgbOut[j + 1] = pixels[i + 1]
rgbOut[j + 2] = pixels[i + 2]
}
const webp = await sharp(rgbOut, {
raw: {
width: sourceBuffer.info.width,
height: sourceBuffer.info.height,
channels: 3,
},
})
.webp({ quality: 90 })
.toBuffer()
await writeFile(join(outputDir, `${name}.webp`), webp)
console.log(`dithered: ${name}.webp`)
}Run it as part of your build:
// package.json
{
"scripts": {
"predither": "tsx scripts/pre-dither.ts",
"build": "pnpm predither && astro build"
}
}Then reference the dithered outputs as static assets in your Astro pages:
<img src="/dithered/portrait.webp" alt="" width={800} height={600} />Zero runtime cost. The images are built once at astro build and
served as regular static files.
Pattern 3 — Custom Astro endpoint (advanced)
If you need server-side dithering in an Astro SSR deployment,
create an endpoint that calls @ditherkit/core + Sharp on demand:
// src/pages/api/dithered/[...params].ts
import { type APIRoute } from 'astro'
import sharp from 'sharp'
import { floydSteinbergDither, grayscale, type Color } from '@ditherkit/core'
export const GET: APIRoute = async ({ params, request }) => {
// ... same pipeline as the Remix workaround, returning a Response
}This is equivalent to the Remix workaround — the code is identical, just in a different route file location.
Example app
A dedicated Astro example app demonstrating both Pattern 1 (client island) and Pattern 2 (build-time) is in progress.
Related docs
@ditherkit/react— for the client island pattern@ditherkit/core— for the build-time pattern- React SPA — same client-side story, different framework