VFX-80 BIOS v2.6 (C) 1986

Memory test ... 640K OK

Loading PHOSPHOR.SYS ... done

MAIN MENU

A:\> run crt.exe

How it works

A cathode-ray tube from gradients and one filter

An old monitor isn't one effect but a handful of physical side effects stacked on top of each other. Each one maps to a cheap CSS or SVG layer, and the text underneath stays real, selectable and readable.

The stack

  1. Content. Plain HTML text in the phosphor color on a dark background. The selected menu entry is inverse video, the same two colors swapped.
  2. Scanlines and phosphor mask, two gradient layers on top of the content.
  3. Tube filter on the wrapper around both: barrel distortion and bloom, so the lines bend and glow together with the text.
  4. Vignette, rolling bar and flicker above the filtered part. They are kept out of the filter on purpose: anything animated inside a filter makes the browser rerun the whole filter on every frame.

1. Scanlines

A CRT draws the picture line by line with an electron beam, and between the lines the phosphor stays dark. A linear-gradient(transparent 50%, rgb(0 0 0 / a) 0) with a background-size height of a few pixels repeats a dark lower half on every line. Using 0 as the second stop position means "same as the previous stop", which makes the edge hard. The line pitch should be a whole number of CSS pixels, otherwise the lines blur into a moiré pattern.

2. The phosphor mask

Color CRTs light red, green and blue phosphor stripes (Trinitron's aperture grille) or dots (shadow mask). The layer is a horizontal gradient with three hard-edged thirds, tiled every three stripe widths, and mix-blend-mode: multiply onto the picture. Each stripe lets its own channel through untouched and dims the other two by the mask strength. At full strength that would cut two thirds of the light, which is why real monitors looked dim and the default here is gentle.

3. Barrel distortion: feDisplacementMap with a generated map

The glass of a CRT is curved, so the picture bulges and its corners pull in. The tube filter starts with <feDisplacementMap>, the same primitive the frosted glass uses with noise. Here the map isn't noise but a small generated image:

  • For coordinates u, v from −1 to 1 and r² = u² + v², barrel distortion samples the source at p · (1 + k·r²), so the offset is k · p · r²: zero in the middle, growing quickly towards the edges.
  • The k-independent part, u·r²/4 and v·r²/4, goes into the red and green channels around 0.5 of a 64×64 image. The PNG is encoded in TypeScript (uncompressed, about 12 KB), so it is the same on the server and in every browser and needs no canvas.
  • <feImage> without a position or size fills the whole filter region, and preserveAspectRatio="none" stretches the map to the screen's shape (the browser smooths it). Giving it an explicit size in pixels looks equivalent, but WebKit drew it about 20px short, which left a strip where the text appeared twice. The displacement scale is 2 × curvature × the shorter side in pixels, which is why the component measures its size: filter units are pixels, not percentages.

Pixels near the corners are fetched from outside the element and come out transparent, which shows the dark bezel color: that is the curved outline of the picture. The displacement picks pixels without smoothing, so very high curvature makes thin strokes jagged; the bloom softens that a little.

4. Bloom

Bright phosphor bleeds light into its surroundings. The warped image is blurred with <feGaussianBlur>, scaled by the glow amount with a linear <feComponentTransfer>, and combined with the sharp image by <feBlend mode="screen">. Screen only ever brightens (1 − (1 − a)(1 − b)), so dark areas stay dark and bright text gets a halo.

<svg width="0" height="0" aria-hidden="true">
  <filter id="crt" x="0" y="0" width="100%" height="100%"
          color-interpolation-filters="sRGB">
    <!-- 64×64 PNG: R = 0.5 + u·r²/4, G = 0.5 + v·r²/4, stretched over the screen -->
    <feImage href="data:image/png;base64,…" preserveAspectRatio="none" result="map" />
    <!-- scale = 2 × 0.05 × shorter side in px -->
    <feDisplacementMap in="SourceGraphic" in2="map" scale="…"
                       xChannelSelector="R" yChannelSelector="G" result="warped" />
    <feGaussianBlur in="warped" stdDeviation="4" />
    <feComponentTransfer result="bloom">
      <feFuncR type="linear" slope="0.6" />
      <feFuncG type="linear" slope="0.6" />
      <feFuncB type="linear" slope="0.6" />
    </feComponentTransfer>
    <feBlend in="warped" in2="bloom" mode="screen" />
  </filter>
</svg>

5. Vignette, rolling bar and flicker

The vignette is a radial gradient plus an inset shadow that darken the edges, like the weaker beam at the tube's rim. The rolling bar is a broad white band at 6% opacity that drifts down every nine seconds, the beat you see when filming a CRT.

.tube { filter: url(#crt); background: #06140c; color: #4dff88; }

/* full-size layers inside .tube, above the content */
.scanlines {
  background: linear-gradient(transparent 50%, rgb(0 0 0 / 0.3) 0)
    0 0 / 100% 3px;
}

.mask {
  background: linear-gradient(90deg,
      rgb(255 217 217) 0 33.333%,
      rgb(217 255 217) 0 66.667%,
      rgb(217 217 255) 0)
    0 0 / 3px 100%;
  mix-blend-mode: multiply;
}

/* above .tube, not filtered */
.vignette {
  background: radial-gradient(ellipse farthest-corner,
    transparent 55%, rgb(0 0 0 / 0.42) 100%);
}

@media (prefers-reduced-motion: no-preference) {
  .bar { animation: roll 9s linear infinite; } /* white at 0.06 */
  .flicker {
    background: rgb(0 0 0 / 0.02); /* never above 0.03 */
    animation: flicker 1s ease-in-out infinite; /* 3 dips */
  }
}

Flicker and photosensitivity

Flicker is the dangerous part. WCAG 2.3.1 allows at most three flashes per second, where a flash is a pair of opposite changes in relative luminance of 10% or more (and the darker state is below 0.8). The flicker here is a black veil whose opacity wobbles between 0 and the Flicker amount, capped at 0.03. The veil scales the gamma-encoded sRGB values, not the light itself: darkening white by 3% in sRGB removes 6.7% of its luminance, because the transfer function is steep at the top. That worst case stays clear of the 10% threshold without leaning on the 0.8 condition; a cap of 0.05 would not (11%). Two more margins on top: there are at most 3 dips per second, and the keyframes are smooth. Each of the six keyframe segments lasts 1/6 s with ease-in-out, whose steepest point is 1.72× the average slope, so one frame at 60 fps changes the luminance by at most 0.012. Both the flicker and the rolling bar are off with prefers-reduced-motion, flicker is opt-in, and the pause button stops all motion on the screen, including the cursor.

Readability

The panel shows the text contrast at the darkest text pixel: phosphor color inside a scanline gap and under the mask stripe that dims it most, against the background. The defaults keep that worst case at 5.2:1, above the 4.5:1 that WCAG asks for body text; real strokes span several lines and stripes, so the perceived contrast is higher. Glow (which brightens the background around letters) and the vignette at the very edges are not part of the number.

Browser support and performance

Checked in Chromium, and in Playwright's Firefox and WebKit builds, which rendered the same. Real Safari is untested. All primitives are long-standing SVG 1.1 filters; the risky part, <feImage> with a data URL inside a CSS filter on HTML, rendered everywhere it was tried.

  • The filter runs over the whole screen. It is cached while the content is static; typing, scrolling or animating inside it reruns displacement and blur every frame. The blinking cursor does that about once a second, which is fine.
  • The blur is the expensive part, growing with the glow radius and the screen size. The gradient layers are practically free.
  • The displacement scale depends on the screen size, so the filter is rebuilt when the screen is resized.

Back to the demo ↑