Skip to content

About

A custom element that renders any content inside a CRT - aperture grille, shadow mask and dot matrix shaders over live, interactive DOM rather than a captured image.

Topics

Resources

Contributing

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

crt-screen

CI License: MIT

Wrap anything in a CRT. Aperture grille, scanlines, vignette, mains flicker — and the content underneath stays live.

Try it →

Dragging a text selection across the CRT panel, then scrolling through the shadow mask, composite and dot-matrix presets

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>

Why

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.

How it stays interactive

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.

Presets

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.

Install

npm install crt-screen
import 'crt-screen';

Things I decided early and stuck to

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.

Testing

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.

The GIF above

Generated, not screen-recorded:

npm run demo &
npm run demo:gif

Playwright 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.

Not built yet

  • 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.

Licence

MIT © OpusDevs

About

A custom element that renders any content inside a CRT - aperture grille, shadow mask and dot matrix shaders over live, interactive DOM rather than a captured image.

Topics

Resources

Contributing

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages