HT-01Case study
Halftones: a printing press inside the browser
Drop in a photo and watch it rebuild as press-style dots, live: 10 shapes, 3 grid geometries, 4 color modes with real screen angles, 8 presets. A hand-written 388-line Canvas 2D engine renders at full native resolution, and nothing ever leaves the tab.
01The idea
The print shop's illusion, made interactive
Halftoning is the print industry's oldest rendering method: solid ink dots, sized by tone, that your eye blends back into a photograph. Every newspaper photo, comic panel, and screen-printed poster works this way. The math is simple, and it normally lives inside prepress software nobody opens for fun.
Halftones takes that algorithm and exposes every knob. Drop a photo in and it redraws as a live amplitude-modulated dot screen: drag the dot size, spacing, or screen angle and the whole image re-renders in place.
Images arrive by drag-and-drop, file browse, or a straight paste from the clipboard, in JPEG, PNG, WebP, or GIF. Adjust, then export the result as a PNG at the source image's full resolution.
Fixed grid, variable dot. Dark tone draws big dots, light tone draws small ones, and below r = 0.3 the paper stays paper.
- Circle
- Square
- Diamond
- Ellipse
- Line Screen
- Cross
- Triangle
- Hexagon
- Star
- Heart
- Square
- Hexagonal · rows offset a half step
- Radial · rings out from center
02The pipeline
From dropped file to dot screen in five stations
One exported function, two canvases: a scratch canvas for pixel work and a visible one for ink.
-
An image, however you hand it over
Drag it in, browse for it, or paste it straight from the clipboard. JPEG, PNG, WebP, and GIF are accepted, and an EXIF pass corrects orientation before anything renders, so phone photos land right-side up.
-
Adjustments on a scratch canvas
The source draws to an offscreen canvas where blur runs through the browser's native
ctx.filter(a GPU Gaussian, no JS convolution), then a single in-place pixel loop applies brightness, contrast, and invert. -
Sample every cell
For each grid cell, the engine averages luminance over a spacing-by-spacing pixel window using ITU-R BT.709 weights. Dot radius is
(1 - luminance) * dotSize, and anything under 0.3 px is skipped entirely. -
Draw in rotated space
The screen angle rotates the whole grid. The engine walks it across the image's diagonal extent so corners stay covered, maps each point back to source pixels, culls anything off-image, and draws in the rotated frame so shapes lean with their screen.
-
Export at native size
The canvas holds the image's full native resolution, not the preview size. Export PNG hands it off through
canvas.toBloband a local object URL. Nothing goes to a server.
// per grid cell, simplified
lum = sampleRegionLuminance(cell) // BT.709
radius = (1 - lum) * dotSize
if (radius < 0.3) skip // paper stays paper
drawDot(shape, cell, radius)
The real file, halftoneEngine.js, runs 388 lines: grid geometry, screen rotation, channel passes, and 10 dot shapes.
Feedback loopEvery control change schedules a re-render, debounced at 200 ms, behind an aria-live processing overlay. Sliders feel live, and the engine never queues a render per pixel of thumb travel.
03Channel separation
Four color modes, four kinds of ink math
Grayscale is the baseline. The other three split the image into screens, and screens at angles are where halftones start looking like print.
-
Mode 01 · Grayscale
One screen, one ink
BT.709 luminance drives dot size on a single screen. This is the newspaper look, and it's where every preset starts making sense.
-
Mode 02 · RGB
Three screens, true separation
Each cell samples its mean red, green, and blue. Three passes run at -15, +15, and 0 degrees, and pure red, green, and blue dots blend with
multiplyover white, so overlaps darken like layered ink. -
Mode 03 · CMYK
Four plates at press angles
Cyan, magenta, yellow, and black plates at the classic +15, +75, +90, and +45, multiply-blended into the rosette that makes comics look like comics. Dot size derives from luminance on all four plates; the limits section has the caveat.
-
Mode 04 · Duotone
Two inks, one ramp
Shadow and highlight pickers. Each dot's fill interpolates between the two inks by tone,
t = 1 - luminance, and the background takes the highlight ink. Retro posters live here.
-
C · +15° #00e5ff
-
M · +75° #e040fb
-
Y · +90° #ffd600
-
K · +45° #000000
- Newspaper
- Magazine
- Comic Book
- Pop Art
- Retro Poster
- Silkscreen
- Thermal Print
- Line Screen
Touch any slider and the active preset highlight clears, because it's no longer that preset.
04Field notes
The parts I'd walk another developer through
Small app, hand-built engine. These are the pieces under the hood that earn their lines.
-
A 50-line EXIF parser, by hand
imageUtils.js reads the JPEG APP1/TIFF header from the first 64 KB with a DataView, handles both byte orders, finds orientation tag 0x0112, and corrects all 8 orientations with canvas transforms. Phone photos load right-side up with no library imports.
-
Rotated screens that reach the corners
The grid walks the image's diagonal extent in rotated space, a forward rotation matrix maps each point back to source pixels, and off-image points get culled. Dots draw in the rotated frame, so a square dot leans with its screen.
-
Shapes weighted by area
Squares and diamonds size at
radius * 1.77to match a circle's ink coverage, so switching shapes never shifts the image's tone. The star is a 10-vertex path with a 0.4r inner radius; the heart is four cubic Bezier curves. -
Two runtime dependencies
React 18 and Bootstrap's CSS, imported once. No canvas or image library, no WebGL. Every dot on screen comes from code in the repo: about 2,550 lines of plain JavaScript and modern CSS across the whole app.
05Privacy survey
Your photo never leaves the tab
Most online image converters ask you to upload your photo. Halftones runs with no server side, no account, and no analytics. Nothing uploads anywhere.
A grep for anything in the source that can open a connection returns nothing.
Runtime network requests · 0
06Margin of error
Open items and known caveats
Four open items, marked on the sheet.
-
One export wired
The UI exports PNG. exportUtils.js already contains JPEG export and copy-to-clipboard, written and tested against the same canvas, but neither is wired to a button yet.
-
CMYK, with a caveat
All four plates derive dot size from luminance, not from per-channel CMYK values. It sells the rosette look; it isn't prepress separation math.
-
Main thread only
Rendering is synchronous Canvas 2D: no Web Workers, no OffscreenCanvas, no WebGL. The 200 ms debounce keeps the controls responsive, but a very large image still renders in one blocking pass.
-
Desktop first
There isn't a single media query in the stylesheet. The canvas scales down through max-width, but the fixed-sidebar layout is built for a desktop screen.
Stack
- React 18
- Plain JavaScript · JSX
- Vite 5
- Canvas 2D
- Bootstrap 5 · CSS only
- Native CSS nesting
By the numbers
- 10 dot shapes
- 4 color modes
- 8 presets
- 388 lines in the engine
- ~2,550 lines in the whole app
- 0 network requests at runtime
08Next traverse
Need real computation to feel instant?
Halftones sits on React and Bootstrap's CSS, with a 388-line engine on top. That's the frontend engineering I take on for Chicago-area clients and remote teams.