Risograph
Turn any image into a live risograph print, right in the browser.
Risograph prints have a texture you can almost feel: a few bright inks laid down one at a time as fields of tiny dots, never quite in register, paper showing through everything. This page rebuilds that look live in the browser, one working print at a time, ending with a full press you can drop your own photos into.
I started this after Jenny Zhu's risograph plugin for Figma made the rounds. It reprints any picture with a handful of inks, each a screen of halftone dots, everything a touch out of register. I kept wanting to poke at the settings mid-print, so I rebuilt the effect on a web page where every knob updates the print as you drag.
The look is simpler than it reads. Split the picture into a few ink layers, halftone each one at its own angle, nudge it a pixel or two out of place, dust it with noise, and multiply the stack over a paper-coloured background. On the GPU that is one small fragment shader, so every print here renders in real time.
The first print was the plainest one I know: a photo on a newspaper page. One coarse dark screen carries the image, a single spot colour warms it, grain finishes it. Drag the dot size up until the picture falls apart, then back until it just resolves. Even this crude version reads as printed rather than filtered, which convinced me to keep going.
Newsprint
One coarse dark screen with a single spot colour and a heavy dusting of grain, like a photo pulled off a newspaper page.
Rendering the print...
Swapping that spot colour around raised a question: why keep the dark screen dark at all? This print splits the picture by tone instead, highlights on one ink, shadows on the other, the way plenty of real riso posters are made. Slide the split and feel the image tip from one colour into the other.
Duotone
Two inks only. Highlights take the first ink, shadows the second, and the split point decides where one gives way to the other.
Rendering the print...
Tuning those two inks, I noticed the misregistration was doing more work than anything else. That pixel or two of drift, an ink never landing exactly where the last one went, is most of what makes a print feel analog. So the obvious experiment was to stop being subtle. Wind the drift up and the layers slide apart until the colours never quite meet, loud and glitchy, like a poster pulled off the drum mid-jam.
Blown registration
Push the layers far out of register and the inks slide apart, so the colours never quite meet and the print reads as loud and analog.
Rendering the print...
All of that folds into the full press below. Pick a palette or edit the inks, then push the dot size, screen angle, registration, grain, and tone; everything updates as you drag. The best subject is your own face. Drop a photo onto the print (it never leaves your browser) and download a hand-printed portrait, ready for an avatar, a cover, or a poster.
Rendering the print...
Next is the paper around the print, the framing that makes an output feel finished: a poster layout with a title block, a sticker with a die-cut border, a blog cover at share-card size. Same inks, different presses.
Code
The real source that powers the demo, ready to copy.
"use client";import { type RefObject, useEffect, useRef } from "react";import { cn } from "@/lib/utils";import { type RisographParams, useRisograph } from "./useRisograph";export type { RisographParams };/** Imperative handle the demo uses to export the current render. */export interface RisographApi { /** Download the current canvas as a PNG. No-op until the render is ready. */ exportPng: () => void;}/** * Drop-in risograph renderer. Give it an image source and a params object and * it paints a live risograph print to a canvas, redrawing whenever the params * change. Self-contained: it owns its own WebGL2 context and cleans up on * unmount. Pass an apiRef to reach the PNG export from outside. */export function Risograph({ src, params, className, apiRef,}: { src: string; params: RisographParams; className?: string; apiRef?: RefObject<RisographApi | null>;}) { const canvasRef = useRef<HTMLCanvasElement>(null); const { status, error, exportPng } = useRisograph(canvasRef, src, params); useEffect(() => { if (!apiRef) return; apiRef.current = { exportPng }; return () => { apiRef.current = null; }; }, [apiRef, exportPng]); return ( <div className={cn( "relative flex min-h-56 w-full items-center justify-center overflow-hidden", className, )} style={{ backgroundColor: params.paper }} > <canvas ref={canvasRef} aria-label="Live risograph render" className={cn( "block h-auto w-full transition-opacity duration-300", status === "ready" ? "opacity-100" : "opacity-0", )} /> {status !== "ready" && ( <div className="absolute inset-0 flex items-center justify-center p-6"> <p className="text-center text-[13px] text-black/50"> {status === "error" ? error : "Rendering the print..."} </p> </div> )} </div> );}Credits
Where this came from.
- Inspired by
- Jenny Zhu's risograph plugin
- Date
- July 2026
- Tags
- WebGLHalftoneImageCanvas
- Author
- Zhiyuan Guo
Zhiyuan Guo
zhiyuanguo/website