# Palettes

How dithers take their colours from your theme, and how to use more than two.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. The dithercn agent skill, for building any dither on the engine, is at [/.well-known/agent-skills/dithercn/SKILL.md](/.well-known/agent-skills/dithercn/SKILL.md).

A palette is the list of colours a dither can use, ordered from paper (tone 0) to ink (tone 1). By default it's two colours, `--dither-paper` and `--dither-ink`, which [`dither-tokens`](/docs/theming) points at your `--background` and `--primary`.

```tsx title="examples/concept-palettes.tsx"
import { DitherPattern, DitherShader } from "@/lib/dither";

const RAMP = `float field(vec2 uv, vec2 res, float t) { return uv.x; }`;

const BARS = [
  { height: 46, level: 0.25 },
  { height: 72, level: 0.5 },
  { height: 58, level: 0.75 },
  { height: 88, level: 1 },
] as const;

export default function ConceptPalettes() {
  return (
    <div className="grid w-full gap-6 sm:grid-cols-[minmax(0,1fr)_10rem] sm:items-end">
      <div className="flex flex-col gap-4">
        <figure className="flex flex-col gap-1.5">
          <DitherShader
            field={RAMP}
            animate={false}
            className="h-12 rounded-lg"
          />
          <figcaption className="text-muted-foreground text-xs">
            Two colours:{" "}
            <span className="text-foreground font-mono">--dither-paper</span> to{" "}
            <span className="text-foreground font-mono">--dither-ink</span>
          </figcaption>
        </figure>
        <figure className="flex flex-col gap-1.5">
          <DitherShader
            field={RAMP}
            animate={false}
            palette={["--background", "--chart-3", "--chart-2", "--chart-1"]}
            className="h-12 rounded-lg"
          />
          <figcaption className="text-muted-foreground text-xs">
            Four colours:{" "}
            <span className="text-foreground font-mono">--background</span> then{" "}
            <span className="text-foreground font-mono">--chart-3</span> to{" "}
            <span className="text-foreground font-mono">--chart-1</span>
          </figcaption>
        </figure>
      </div>
      <figure className="flex flex-col gap-1.5">
        <svg
          viewBox="0 0 160 100"
          className="h-auto w-full"
          role="img"
          aria-label="Four bars filled at 25, 50, 75 and 100 percent ink"
        >
          <defs>
            {BARS.map(({ level }) => (
              <DitherPattern
                key={level}
                id={`palette-bar-${level}`}
                level={level}
                cell={3}
              />
            ))}
          </defs>
          {BARS.map(({ height, level }, index) => (
            <rect
              key={level}
              x={index * 40 + 4}
              y={100 - height}
              width={32}
              height={height}
              fill={`url(#palette-bar-${level})`}
            />
          ))}
        </svg>
        <figcaption className="text-muted-foreground text-xs">
          <span className="text-foreground font-mono">DitherPattern</span> fills
        </figcaption>
      </figure>
    </div>
  );
}
```

## More than two colours [#more-than-two-colours]

Components that draw with `DitherShader` take a `palette` prop with two or more colours. The tone is spread across the list, and each cell is dithered between the two neighbouring colours. With four colours, a tone of 0.5 sits halfway between the second and third:

```tsx
<DitherGradient
  palette={["--background", "--chart-3", "--chart-2", "--chart-1"]}
/>
```

Each entry is a CSS variable name such as `"--chart-1"`, or any CSS colour: `"var(--chart-1)"`, `"oklch(0.72 0.15 272)"`, `"#7c8cff"`, even `"transparent"` to let the page show through the paper. The type is `DitherPalette`, a tuple of at least two `DitherColor` strings, so a one-colour palette fails to compile.

Order matters more than hue. Put colours in order of how much they stand out against the paper, or the ramp will jump back and forth.

## Any number of hues [#any-number-of-hues]

A field returns one tone per cell, and the palette turns that tone into a colour. So with a palette of several hues, the tone decides both how dense the dots are and which colour they take. That suits ramps, such as a glow that runs from deep blue to white, but it can't draw a red line and a blue line at the same strength, or a spectrum that runs along a line.

For that, add a `colorField`: a second function that returns the colour of the dots in each cell. The field still decides where the dots go, and cells without a dot are paper, `palette[0]`:

```tsx title="examples/concept-hues.tsx"
import { DitherShader } from "@/lib/dither";
import type { DitherShaderProps } from "@/lib/dither";

// Shared by both functions: the nearest line to this cell, as
// (distance in line widths, line index).
const LINES = `
vec2 spectrumNearest(vec2 uv, vec2 res, float t) {
  float x = uv.x * res.x / res.y;
  vec2 best = vec2(1e4, 0.0);
  for (int i = 0; i < 9; i++) {
    if (float(i) >= spectrumLines) break;
    float k = float(i);
    float base = 0.18 + 0.64 * k / max(spectrumLines - 1.0, 1.0);
    float y = base
      + 0.035 * sin(x * 2.4 + t * 0.5 + k * 0.9)
      + 0.015 * sin(x * 5.3 - t * 0.35 + k * 1.7);
    float d = abs(uv.y - y) * res.y / spectrumWidth;
    if (d < best.x) best = vec2(d, k);
  }
  return best;
}
`;

// Where the dots go: a thin core and a soft glow around each line.
const FIELD = `${LINES}
float field(vec2 uv, vec2 res, float t) {
  float d = spectrumNearest(uv, res, t).x;
  return exp(-d * d) + 0.35 * exp(-d * 0.6);
}
`;

// What colour they are: a rainbow that runs along each line and drifts,
// shifted a little per line. Mixing in the theme's ink keeps it readable
// on white as well as black.
const COLOR_FIELD = `
vec3 colorField(vec2 uv, vec2 res, float t) {
  float k = spectrumNearest(uv, res, t).y;
  vec3 hue = spectrum(uv.x * 0.75 + k * 0.06 - t * 0.03);
  return mix(hue, paletteColor(0.0).rgb, 0.2);
}
`;

type SpectrumLinesProps = Omit<
  DitherShaderProps,
  "field" | "colorField" | "uniforms"
> & {
  /** How many lines, from 1 to 9. */
  lines?: number;
  /** Line thickness in cells; the glow spreads a few times wider. */
  width?: number;
};

const SpectrumLines = ({
  lines = 7,
  width = 1.2,
  palette = ["--background", "--foreground"],
  ...props
}: SpectrumLinesProps) => (
  <DitherShader
    {...props}
    field={FIELD}
    colorField={COLOR_FIELD}
    palette={palette}
    stillTime={3}
    uniforms={{ spectrumLines: lines, spectrumWidth: width }}
  />
);

export default function ConceptHues() {
  return <SpectrumLines matrix="bayer8" className="size-full" />;
}
```

```tsx
const COLOR_FIELD = `
vec3 colorField(vec2 uv, vec2 res, float t) {
  return spectrum(uv.x * 0.75 - t * 0.03);
}
`;

<DitherShader
  field={FIELD}
  colorField={COLOR_FIELD}
  palette={["--background", "--foreground"]}
/>;
```

It takes the same arguments and uniforms as the field, and returns `vec3` RGB or `vec4` RGBA. If TypeScript says `colorField` doesn't exist, your copy of the engine is older than it: [update the engine](/docs/installation#update-the-engine). Two helpers come with it:

* `spectrum(h)` is a saturated rainbow: 0 is red, then yellow, green, cyan, blue and magenta, and red again at 1. It wraps, so `h` can keep growing with time.
* `paletteColor(x)` reads the palette's ink colours, every entry after the paper, as a smooth ramp from 0 to 1. Use it to colour by position in your theme colours: `return paletteColor(uv.x).rgb;` with `palette={["--background", "--chart-1", "--chart-2", "--chart-3"]}`.

Soft tones still dither, so a coloured glow fades out in sparse dots of its colour. To colour several shapes differently, work out which shape a cell is in with a helper. Both strings compile into one shader: define the helper in the field string, or prepend the same helpers to both (the engine keeps one copy), and call it from both functions; the demo above finds the nearest line that way and shifts each line's hue.

`spectrum` is fully saturated, so check light mode: yellow and cyan nearly vanish on white. The demo mixes a fifth of the theme's foreground into every hue, `mix(spectrum(h), paletteColor(0.0).rgb, 0.2)`, which keeps it readable in both modes.

If the colour has to come from outside the shader, such as an image or a CSS gradient, put that layer under the shader and use `palette={["--background", "transparent"]}`: tone 0 paints the background and tone 1 shows the layer.

## Following the theme [#following-the-theme]

Colours are resolved in the browser, in the context of the component, so a variable you override on a wrapper applies to everything inside it. Formats the GPU can't read, such as `oklch()`, are converted by painting them once into a 1×1 canvas.

When the class or style on `<html>` changes, as it does when next-themes switches between light and dark, every dither re-reads its colours and redraws. Nothing else needs to happen.

For your own canvas work, `useDitherColors` does the same and returns the colours as `[r, g, b, a]` in \[0, 1]:

```tsx
const ref = useRef<HTMLDivElement>(null);
const colors = useDitherColors(ref); // [ink, paper], or null before mount
```

Pass a list of names as the second argument to read other variables, such as `useDitherColors(ref, ["--chart-1", "--chart-2"])`.

## Static fills [#static-fills]

`DitherPattern` takes a `color` and an optional `background`, and `ditherMaskStyle` masks whatever background colour the element already has. Both accept CSS variables, so they follow the theme without JavaScript.

## Contrast [#contrast]

A dither reads only when ink and paper differ enough. For charts and anything that carries information, aim for the contrast you would want for body text, about 4.5:1. Quiet backgrounds can go lower, since nobody needs to read them. The defaults, your primary on your background, usually clear that bar in both modes.