I wanted UI that looks printed. 🖨️ A dither turns a smooth tone into a pattern of ink and paper dots, and I built a small library that does it for Compose Multiplatform: Stipple.
It runs on Android, iOS, desktop and the browser from one commonMain. Live demo, repo.
🤔 Why
I noticed how popular dither is in web development, and I like that kind of niche visual style. There were dithered components for React, and a few Compose experiments, but I couldn't find a small engine in common code that you can aim at any composable.
🧩 Three swappable pieces
- a field says how much ink each point wants (0 is paper, 1 is ink),
- a pattern decides which cells turn on (Bayer 2/4/8, blue noise or your own),
- a theme holds the colours and the cell size.
A field is a pure function, so a new look is one lambda:
private val spiral = DitherField { x, y, t, aspect ->
val dx = (x - 0.5f) * aspect
val dy = y - 0.5f
0.5f + 0.5f * sin(hypot(dx, dy) * 14f - atan2(dy, dx) * 2f - t * 2f)
}
Box(Modifier.size(240.dp).dither(spiral, DitherPatterns.Bayer4))
🧱 Two renderers, one picture
The cell renderer works everywhere, back to Android 6. It samples the field once per cell and merges neighbouring cells of one colour into a single rectangle. The shader renderer draws the whole element in one fragment shader (AGSL on Android 13+, a Skia runtime effect elsewhere) and falls back to cells where it can't run.
🐛 The hash that wouldn't port
To draw the noise field in the shader I had to port its hash. My CPU hash used 32-bit integer overflow and shifts, and AGSL has no reliable version of those. So I switched to a float-only hash and made the cell renderer use it too, so both renderers draw exactly the same noise. The catch, straight from the changelog: which cells light up for a given noise seed changes; the look does not. Golden screenshots had to be regenerated in the same commit.
♿ Reduced motion
Stipple reads the platform's reduced-motion setting and freezes animations on a still frame. Progress and values keep updating, because a progress bar that stops moving hides information. Only decoration freezes.
🙈 What I have NOT verified
- the shader renderer on iOS (the cell renderer and gallery are checked on an iPhone 17 simulator, no physical iPhone),
- Android 6 to 12 on a real device (Robolectric only), and other Android 13+ phones than my Samsung on Android 15,
- Firefox and Safari itself (Chromium and WebKit through Playwright),
- frame times on low-end Android.
🚀 Try it
commonMain.dependencies {
implementation("io.github.jantursky:stipple:1.1.0")
}
If you have an iPhone, the most useful thing you can do is open the gallery and tell me whether the shader renderer draws correctly. Anything else, tell me in the comments. 🙂

Top comments (0)