Halftone Print
Print a scene onto paper — rotated dot screens per colour, slightly out of register
This filter re-reads the real pixels of the scene it wraps through the experimental html-in-canvas browser API. The preview above shows the true effect in Chrome 149+ with chrome://flags/#canvas-draw-element enabled (nested canvases need 152.0.7944.0+). Every other browser — Safari, Firefox, Chrome without the flag — falls back to a CSS approximation, so the page and your video still play.
Rendering is supported through the CLI, Studio, Lambda and SSR with --gl=angle, or --gl=swangle on a machine without a GPU. Chrome may change or discontinue the API; the fallback is what keeps your composition renderable if that happens.
Installation
$ pnpm dlx shadcn@latest add @remocn/halftone-printUsage
HalftonePrint is a wrapper. Put a scene inside it and it comes out as a printed
page: the picture separates into cyan, magenta and yellow, each screened onto its
own grid of dots rotated to its own angle, each dot sized by how much of that ink
belongs under it. The three screens land slightly out of register, and what shows
between them is paper.
import { HalftonePrint } from "@/components/remocn/halftone-print";
export const MyScene = () => (
<HalftonePrint>
<Scene />
</HalftonePrint>
);What to reach for it for
There is no master switch here, and that is deliberate: fading print back toward a clean screen render is not a thing presses do. What print does have is two honest arrivals — plates finding register, and a screen getting finer — and both are just numbers read every frame.
Let the press find register
The strongest opening. The plates start badly out of alignment and pull together over the first second, so the page arrives by being printed rather than by fading in. It is a move with a mechanical reason behind it, which is why it reads as craft rather than as an effect.
import { interpolate, useCurrentFrame } from "remotion";
import { HalftonePrint } from "@/components/remocn/halftone-print";
export const Opening = () => {
const frame = useCurrentFrame();
const misregistration = interpolate(frame, [0, 36], [9, 1.2], {
extrapolateRight: "clamp",
});
return (
<HalftonePrint misregistration={misregistration}>
<Poster />
</HalftonePrint>
);
};The preview drifts back out at the end so that it loops; a real opening would settle and stay.
Resolve from newsprint to offset
The other arrival: start coarse and let the screen get finer. Where the register move feels like a press starting up, this one feels like the image itself coming into focus, and it survives on scenes with less colour to separate.
const dotSize = interpolate(frame, [0, 40], [30, 7], {
extrapolateRight: "clamp",
});The screen belongs to the frame
Worth knowing before you animate anything underneath: the dot grid is fixed to the frame, not to the picture. Move the scene and it travels through a stationary screen, re-sampled every frame — the same way a moving subject would be re-screened on every printed page of a flipbook, not carried along by its dots.
That is what makes it safe to put animation inside the wrapper. It is also why
pushing a scene around behind a coarse screen shimmers: at dotSize above the
low twenties, cells flip between ink levels as content crosses them. Keep the dots
fine when the content moves, and save the coarse screens for scenes that hold.
Feed it a light scene
This is the one filter in the pack with a strong opinion about what it wraps. A dot screen renders tone by varying dot size, so it needs tone to render: on a light, graphic scene the dots stay small and separate and the paper does the work. On a dark scene nearly every cell saturates, the dots merge, and the result is a flat field with none of the character.
If the scene you want to print is dark, invert it before wrapping rather than
turning dotSize up.
Registration
misregistration is how far the three screens drift apart, in composition units.
The default is a fraction of a dot — enough that colour edges fringe the way a
real press does, not enough to read as a mistake. Push it past a few units and it
becomes the effect rather than the texture, which is a legitimate riso look but a
deliberate one.
Setting it to 0 gives a clean digital separation, which reads as a print
simulation rather than as print.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
dotSize | number | 10 | Width of one screen cell in composition units, and so the pitch of the dots. Small values read as fine offset, large values as coarse newsprint. |
angle | number | 0 | Rotation in degrees applied to all three screens together. The 15/75/0 spread between them is fixed, since that is what produces a rosette instead of a moiré; this turns the whole set. |
misregistration | number | 1.2 | How far the colour screens drift out of alignment, in composition units. Each channel samples the scene at its own offset, so edges fringe. 0 prints in perfect register. |
paperTint | string | "#f4efe4" | The stock being printed on. It shows wherever ink does not cover, so it sets the whole temperature of the result. |
children | React.ReactNode | — | The scene the filter wraps and re-reads every frame. |
Notes
Nothing animates. The screen is fixed geometry and the dots re-measure the scene every frame, so a still scene is pixel-identical frame to frame and a moving one prints as it moves — no shimmer of its own to loop or drift.
Paper is emitted only where the scene is opaque, so wrapping a transparent element does not box it into a sheet. Inside opaque areas the output is fully covered, so the untouched scene beneath never shows through the gaps between dots.
In browsers without html-in-canvas the scene renders untouched. A flat paper tint over the frame was the obvious fallback and it was rejected: it would claim the scene had been printed when nothing had been screened at all.