ditherkit
Integrations

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/react in 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/core at build time — call @ditherkit/core directly from an Astro integration hook, or from a standalone build script that runs before astro 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/next is 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-dither integration 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/react

Create 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 sharp

Write 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.

On this page