Wrap anything in a CRT. Aperture grille, scanlines, vignette, mains flicker — and the content underneath stays live.
That selection being draggable is the entire argument. You can't do that to a picture of a terminal.
<crt-screen preset="trinitron">
<h1>Still a real h1. Still selectable. Still clickable.</h1>
</crt-screen>Every CRT effect I've seen on the web is a scanline PNG at 8% opacity over a
border-radius. It reads as a sticker sitting on top of a flat page, because
that's exactly what it is.
The parts that actually sell it are the ones nobody bothers with. Shadow masks are RGB subpixels, so the mask should tint per-channel rather than just darken. Scanlines have a pitch measured in device pixels, so they shouldn't blur on a retina display. Mains hum is a slow brightness wobble, not a strobe. All of that is one fragment shader.
The hard part was never the shader. It was keeping the content underneath alive.
The canvas sits above your content with pointer-events: none and
mix-blend-mode: multiply, and the shader writes a multiplier into it. White
leaves a pixel alone; darker values are the mask, the scanlines and the vignette.
Nothing is captured, nothing is rasterised, so the DOM underneath is untouched: text selects, buttons click, screen readers work, and your layout is still your layout.
The trade-off is that there's no barrel curvature. Bending geometry means having the content as a texture, and the moment you do that you're maintaining an inverse-mapping for pointer coordinates so clicks still land in the right place. That's a real feature and it's not built yet — see PLAN.md. What's here is the version you can put on a real site today.
| Preset | What it's going for |
|---|---|
trinitron |
Aperture grille — sharp vertical RGB stripes, shallow scanlines, colour intact |
shadowmask |
Offset dot triads, softer lines, heavier vignette. What most consumer sets looked like |
broadcast |
Composite: wide channel misconvergence and a slow roll |
vt220 |
Monochrome amber, heavy scanlines, no mask |
handheld |
Dot-matrix LCD grid, no scanlines and no glow, greenish backlight |
Every uniform is also a CSS custom property, so a preset is a starting point:
<crt-screen preset="trinitron" style="--crt-mask: dot-matrix; --crt-scanline-depth: 0.05">A mask name the shader doesn't implement is ignored rather than guessed at, and
nonsense in a custom property gets clamped into range instead of handing the GPU
a NaN.
npm install crt-screenimport 'crt-screen';Reduced motion removes movement, not the look. Flicker and roll are exactly
the effects that trigger migraine and vestibular symptoms, so
prefers-reduced-motion: reduce zeroes them — and the mask, scanlines, tint and
vignette all stay. A test asserts every preset still looks like something with
the motion off, because "accessible" shouldn't mean "blank".
No WebGL, no problem. If the context won't come up or the program won't link, the canvas is removed and your children render plainly. It's decoration; it should degrade like decoration.
One rAF loop for the whole page. Ten screens must not mean ten render loops, or this only ever gets used once in a demo.
No dependencies. Raw WebGL2.
34 unit tests, over the parts where a decision gets made: uniform resolution and clamping, tint parsing, the reduced-motion store, and the shared scheduler.
The GL code isn't unit tested and I don't think it should be — mocking a GL context would only assert that it called the functions it obviously calls. It gets checked by rendering instead. Reading pixels back off a 64×64 render with the trinitron preset gives:
- consecutive pixels across a row coming back R-dominant, G-dominant, B-dominant — the aperture triad
- luminance oscillating down a column on the configured 3px period
- the centre brighter than the corner
Which is a stronger statement about whether it works than any mock would be.
Generated, not screen-recorded:
npm run demo &
npm run demo:gifPlaywright drives the demo and gifenc encodes the frames — Playwright's own
ffmpeg is a stripped webm-only build with no GIF muxer. One palette is shared
across every frame, which matters more here than usual: the masks are
fine-grained, and per-frame quantising makes them shimmer, which is precisely
the artefact this effect must not have.
- Texture mode, and therefore curvature. The interesting half of that is the pointer inverse-mapping.
- Phosphor persistence and bloom. The uniforms exist and are plumbed; the passes aren't written.
- A WebGL1 fallback path. Currently it's WebGL2 or the plain content.
MIT © OpusDevs