How it works

Two ways to print with dots

Printers fake grey with patterns of ink and bare paper. On the web there are two very different ways to get that look: a CSS trick that grows real round dots, and an SVG filter that quantizes pixels with a threshold pattern.

Mode 1: CSS halftone with a contrast() threshold

A halftone screen is a grid of dots whose size follows the image: big dots in the shadows, tiny ones in the highlights. The CSS version builds that from three layers and one filter:

  1. The source, blurred. blur() averages away detail smaller than a dot (here 0.3 × the dot size), grayscale(1) drops the color in monochrome mode, and brightness(0.5) squeezes the brightness L into 0–0.5.
  2. The dot grid. A radial-gradient tiled with background-size: black in the center of each cell, 50% grey at the corners. The layer is twice the size of the element and turned with rotate, which gives the screen angle.
  3. Add, then threshold. mix-blend-mode: plus-lighter adds the dot grid to the source. Every pixel now holds g + L/2, where g is its place in the dot gradient. Then filter: contrast() with a big factor (up to 100) on the parent pushes everything below 0.5 to black and everything above to white. A pixel ends up as ink when g < (1 − L)/2: near the dot center in light areas, almost everywhere in dark ones. Each dot grows with the darkness around it.

The gradient isn't linear. Its grey level grows with the square of the distance from the center (written out as thirteen color stops), so the dot area, not its radius, grows in step with darkness, the way a print screen works. Dots touch at about 79% darkness; after that the gradient closes the remaining holes in the corners. Softness lowers the contrast factor, which leaves a blurry rim around every dot.

The output is black and white (or eight RGB/CMY colors when every channel is screened on its own). Ink and paper colors take three more full-size layers above it, all plain CSS blend modes working per channel: difference with white flips the channels where the ink is lighter than the paper, lighten with the smaller value lifts black to the ink, darken with the larger value pulls white down to the paper. For pure black and white that is exact, for any pair of colors.

<div class="halftone">
  <div class="threshold">
    <div class="source"><!-- the image --></div>
    <div class="dots"></div>
  </div>
  <div class="ink invert"></div>
  <div class="ink lighten"></div>
  <div class="ink darken"></div>
</div>

.halftone {
  position: relative;
  isolation: isolate; /* the ink layers blend only with the halftone */
  overflow: hidden;
}

.threshold, .source { height: 100%; }

.threshold {
  position: relative;
  overflow: hidden;
  background: rgb(50% 50% 50%); /* transparent parts count as paper */
  filter: contrast(21.74); /* threshold at 50 % */
}

.source {
  filter: grayscale(1) blur(3px) brightness(0.5);
}

.dots {
  position: absolute;
  inset: -50%; /* oversized, so the rotated grid covers the corners */
  rotate: 45deg;
  background: radial-gradient(circle farthest-corner,
      rgb(0% 0% 0%) 0%,
      rgb(0.55% 0.55% 0.55%) 8.33%,
      rgb(2.18% 2.18% 2.18%) 16.67%,
      rgb(4.91% 4.91% 4.91%) 25%,
      rgb(8.73% 8.73% 8.73%) 33.33%,
      rgb(13.64% 13.64% 13.64%) 41.67%,
      rgb(19.63% 19.63% 19.63%) 50%,
      rgb(26.73% 26.73% 26.73%) 58.33%,
      rgb(34.91% 34.91% 34.91%) 66.67%,
      rgb(40.61% 40.61% 40.61%) 75%,
      rgb(43.44% 43.44% 43.44%) 83.33%,
      rgb(46.57% 46.57% 46.57%) 91.67%,
      rgb(50% 50% 50%) 100%
    ) center / 10px 10px;
  mix-blend-mode: plus-lighter;
}

/* black → ink, white → paper */
.ink { position: absolute; inset: 0; }
.invert  { background: #000000; mix-blend-mode: difference; }
.lighten { background: #1b1a3a; mix-blend-mode: lighten; }
.darken  { background: #f6efe0; mix-blend-mode: darken; }

Mode 2: SVG posterize and ordered dither

Dithering works per pixel instead: every pixel becomes one of a few levels, and a fixed pattern of thresholds decides which way it rounds, so the share of lit pixels in a small area matches the original brightness.

feComponentTransfer
With type="discrete", n values in tableValues split the input into n equal bins and output the bin's value: a step function, which is posterize. Two levels give pure ink or paper, three add one middle tone, and so on.
Bayer matrix
The threshold pattern. It is generated recursively (M₂ₙ = [[4M, 4M+2], [4M+3, 4M+1]]), so every value appears once and neighboring cells are as different as possible. That spreads the lit pixels evenly and gives ordered dither its crosshatch look.
feImage + feTile
The matrix is written as a tiny SVG with one grey square per cell, passed to <feImage> as a data URL and repeated over the whole element by <feTile>. Pattern pixel sets the size of a cell in CSS pixels.
feComposite
operator="arithmetic" adds the threshold to the image before quantizing: C′ = C·(L−1)/L + t/L for L levels. A discrete table then picks level ⌊C·(L−1) + t⌋, which is exactly ordered dither. Without dither the threshold is a flat 50% grey from <feFlood>, which turns the same math into plain rounding.

In monochrome mode a luminance <feColorMatrix> (Rec. 709 weights, as on the duotone page) runs first, and a last type="table" transfer maps 0 to the ink and 1 to the paper. A final <feComposite operator="in"> against SourceGraphic cuts the result back to the source's shape: the threshold pattern is opaque, so without it transparent parts of the source would come out with a faint pattern.

<svg width="0" height="0" aria-hidden="true">
  <filter id="dither" x="0" y="0" width="100%" height="100%"
          color-interpolation-filters="sRGB">
    <feColorMatrix type="matrix" result="source" values="
      0.2126 0.7152 0.0722 0 0  0.2126 0.7152 0.0722 0 0
      0.2126 0.7152 0.0722 0 0  0 0 0 1 0" />
    <!-- 4×4 Bayer thresholds, 2px per cell, as an SVG data URL -->
    <feImage href="data:image/svg+xml,…" x="0" y="0"
             width="8" height="8" result="cell" />
    <feTile in="cell" result="threshold" />
    <feComposite in="source" in2="threshold"
                 operator="arithmetic" k2="0.5" k3="0.5" />
    <feComponentTransfer>
      <feFuncR type="discrete" tableValues="0 1" />
      <feFuncG type="discrete" tableValues="0 1" />
      <feFuncB type="discrete" tableValues="0 1" />
    </feComponentTransfer>
    <feComponentTransfer> <!-- 0 → ink, 1 → paper -->
      <feFuncR type="table" tableValues="0.1059 0.9647" />
      <feFuncG type="table" tableValues="0.102 0.9373" />
      <feFuncB type="table" tableValues="0.2275 0.8784" />
    </feComponentTransfer>
    <!-- keep the source's shape: the threshold layer is opaque -->
    <feComposite in2="SourceGraphic" operator="in" />
  </filter>
</svg>

Which one looks how

  • Halftone looks like print: soft, round, resolution-independent dots at any angle. It loses fine detail on purpose (the blur), and because of the threshold it only knows two states per channel.
  • Dither looks like early computer graphics: crisp, square, axis-aligned pixels. It keeps more detail and can do any number of levels, but the pattern is always on the pixel grid and doesn't rotate.
  • Neither keeps text readable. Apply them to images and backgrounds, and keep real content outside the effect.

Browser support and costs

Both modes were checked in Chromium and in Playwright's Firefox and WebKit builds, which rendered them the same; real Safari is untested. The CSS mode needs mix-blend-mode: plus-lighter, which all current engines support. The SVG mode relies on <feImage> loading a data URL inside a filter applied to HTML; it worked in all three.

  • The CSS mode costs a blur, an extra blended layer and a filter over the whole element (plus three blended layers for ink and paper). Large dots mean a large blur radius, which is the expensive part, especially when the source is animated.
  • The SVG mode is a handful of per-pixel operations with no blur, so it is cheap and its cost doesn't depend on the settings. Pattern cells are sized in CSS pixels, so on a high-DPI screen a 1px pattern cell covers several device pixels.
  • Static sources are rendered once and cached; animated ones are re-screened every frame.

Back to the demo ↑