Tailwind v4MIT licensed

Tailwind CRT Scanlines and Screen Curvature

A CRT look is not one effect, it is four: scanlines, curvature, phosphor bloom and mains flicker. Each is a separate layer with its own checkbox, so you can stack them one at a time and see what each contributes. The port is instructive because three of the four move cleanly into the class list — and the fourth, the fractional-DPR scanline fix, is the demo's whole reason for keeping a stylesheet.

Live Demo
Plain CSSTailwind v4
Try it

Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time

Untitled UI React · Affiliate link

Get Untitled UI Pro

The code

<section class="twr-01 relative block w-full min-h-svh box-border bg-twr01-bg text-twr01-ink font-twr01-mono" aria-labelledby="twr-01-title">
  <h2 class="absolute size-px overflow-hidden [clip-path:inset(50%)] whitespace-nowrap" id="twr-01-title">CRT scanline and curvature layer stack</h2>

  <div class="absolute top-4 right-4 z-6">
    <input class="peer absolute inset-0 size-11 m-0 opacity-0 cursor-pointer" type="checkbox" id="twr-01-theme">
    <label class="grid place-items-center size-11 rounded-full border border-twr01-line bg-twr01-surface text-twr01-ink2 cursor-pointer peer-focus-visible:outline-2 peer-focus-visible:outline-twr01-acc peer-focus-visible:outline-offset-2" for="twr-01-theme">
      <svg class="size-5" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 4a8 8 0 0 0 0 16Z" fill="currentColor"/></svg>
      <span class="absolute size-px overflow-hidden [clip-path:inset(50%)] whitespace-nowrap">Toggle dark surround</span>
    </label>
  </div>

  <div class="grid grid-cols-[minmax(0,1fr)] gap-6 max-w-[74rem] mx-auto px-[clamp(0.85rem,0.5rem+2vw,2rem)] pt-[clamp(3.5rem,3rem+3vw,5rem)] pb-16">
    <p class="m-0 justify-self-start min-w-0 rounded-sm border border-twr01-line bg-twr01-surface px-2.5 py-1.5 text-[0.7rem] tracking-[0.16em] uppercase text-twr01-ink2">Cathode-ray tube &middot; shadow-mask display</p>

    <div class="grid grid-cols-[minmax(0,1fr)] items-start gap-5 lg:grid-cols-[minmax(0,1.55fr)_minmax(0,1fr)] lg:gap-8">
      <div class="relative min-w-0 rounded-[44px] p-[clamp(0.9rem,0.6rem+1.4vw,1.6rem)] [background:linear-gradient(180deg,var(--color-twr01-case)_0%,var(--color-twr01-case2)_100%)] [box-shadow:inset_0_2px_0_rgba(255,255,255,0.55),inset_0_-2px_0_rgba(0,0,0,0.35),0_18px_40px_-18px_rgba(0,0,0,0.65)]">
        <div class="twr-01__screen relative grid place-items-center isolate overflow-hidden aspect-4/3 rounded-[var(--twr-01-curve)] transition-[border-radius] duration-[240ms] [background:radial-gradient(120%_100%_at_50%_45%,#0a2018_0%,var(--color-twr01-tube)_72%)] [box-shadow:inset_0_0_0_2px_#06120d,inset_0_0_30px_6px_rgba(0,0,0,0.75)]">
          <div class="relative z-2 grid justify-items-center gap-2 min-w-0 p-[clamp(0.75rem,0.4rem+2vw,2rem)] text-center text-twr01-glow">
            <p class="m-0 text-[clamp(0.6rem,0.45rem+0.7vw,0.85rem)] tracking-[0.1em] text-twr01-glowdim">SYSTEM MONITOR &mdash; VT/CRT COMPOSITE</p>
            <p class="relative m-0 font-bold leading-none text-[clamp(1.6rem,0.6rem+6.5vw,4rem)] tracking-[0.14em]">PHOSPHOR<span class="absolute inset-0 grid place-items-center pointer-events-none mix-blend-screen blur-[3px] transition-opacity duration-200 [opacity:calc(0.85*var(--twr-01-bloom-on))]" aria-hidden="true">PHOSPHOR</span></p>
            <p class="m-0 text-[clamp(0.6rem,0.45rem+0.7vw,0.85rem)] tracking-[0.1em]">SCAN 15.734 kHz &nbsp; VERT 59.94 Hz</p>
            <p class="m-0 text-[clamp(0.6rem,0.45rem+0.7vw,0.85rem)] tracking-[0.1em]">MASK PITCH 0.28 mm &nbsp; DEFL 90&deg;</p>
            <p class="m-0 text-[clamp(0.6rem,0.45rem+0.7vw,0.85rem)] tracking-[0.1em] text-twr01-glowdim">READY<span class="inline-block w-[0.6em] h-[1em] ms-[0.3em] align-[-0.15em] bg-twr01-glow animate-twr01-blink motion-reduce:animate-none" aria-hidden="true"></span></p>
          </div>


          <div class="twr-01__scan absolute inset-0 z-4 pointer-events-none transition-opacity duration-200 [background-image:repeating-linear-gradient(to_bottom,rgba(0,0,0,0)_0,rgba(0,0,0,0)_calc(var(--twr-01-scan)-1px),rgba(0,0,0,0.72)_calc(var(--twr-01-scan)-1px),rgba(0,0,0,0.72)_var(--twr-01-scan))] [opacity:calc(0.55*var(--twr-01-scan-on))]" aria-hidden="true"></div>

          <div class="absolute -inset-px z-5 pointer-events-none transition-opacity duration-200 [background:radial-gradient(115%_105%_at_50%_50%,rgba(0,0,0,0)_52%,rgba(0,0,0,0.55)_84%,rgba(0,0,0,0.9)_100%),linear-gradient(90deg,rgba(255,255,255,0.05),rgba(255,255,255,0)_18%_82%,rgba(255,255,255,0.05))] [opacity:var(--twr-01-curve-on)]" aria-hidden="true"></div>

          <div class="twr-01__flick absolute inset-0 z-6 pointer-events-none opacity-0 bg-[rgba(120,255,190,0.05)] animate-twr01-flicker motion-reduce:animate-none" aria-hidden="true"></div>
        </div>

        <div class="flex items-center justify-between gap-3 min-w-0 pt-3 px-1.5 pb-0.5">
          <span class="min-w-0 text-[0.62rem] tracking-[0.22em] text-[#3c4038] [text-shadow:0_1px_0_rgba(255,255,255,0.45)]">TRINITRON&nbsp;/&nbsp;14"</span>
          <span class="flex-none size-[9px] rounded-full bg-[#63f0a0] [box-shadow:0_0_8px_2px_rgba(99,240,160,0.7),inset_0_0_2px_#0a3a22]" aria-hidden="true"></span>
        </div>
      </div>

      <ul class="list-none m-0 p-0 min-w-0 grid grid-cols-[minmax(0,1fr)] content-start gap-2 lg:gap-2.5">
        <li class="relative min-w-0">
          <input class="peer absolute top-0 left-0 size-11 m-0 opacity-0 cursor-pointer" type="checkbox" id="twr-01-scan" checked>
          <label class="grid grid-cols-[44px_minmax(0,1fr)] items-center gap-x-3 min-h-14 cursor-pointer rounded-[3px] border border-twr01-line bg-twr01-surface py-2 pr-3 pl-2 peer-checked:border-twr01-ink2 peer-checked:[&_[data-n]]:bg-twr01-ink peer-checked:[&_[data-n]]:border-twr01-ink peer-checked:[&_[data-n]]:text-twr01-surface peer-focus-visible:outline-2 peer-focus-visible:outline-twr01-acc peer-focus-visible:outline-offset-2" for="twr-01-scan">
            <span class="row-span-2 grid place-items-center size-[34px] ms-[5px] rounded-sm border-2 border-twr01-ink2 text-[0.72rem] tabular-nums text-twr01-ink2" data-n>01</span>
            <span class="min-w-0 text-sm font-semibold tracking-[0.02em]">Scanlines</span>
            <span class="min-w-0 text-[0.68rem] text-twr01-ink2 [overflow-wrap:anywhere]">repeating gradient, 3px period</span>
          </label>
        </li>
        <li class="relative min-w-0">
          <input class="peer absolute top-0 left-0 size-11 m-0 opacity-0 cursor-pointer" type="checkbox" id="twr-01-curve" checked>
          <label class="grid grid-cols-[44px_minmax(0,1fr)] items-center gap-x-3 min-h-14 cursor-pointer rounded-[3px] border border-twr01-line bg-twr01-surface py-2 pr-3 pl-2 peer-checked:border-twr01-ink2 peer-checked:[&_[data-n]]:bg-twr01-ink peer-checked:[&_[data-n]]:border-twr01-ink peer-checked:[&_[data-n]]:text-twr01-surface peer-focus-visible:outline-2 peer-focus-visible:outline-twr01-acc peer-focus-visible:outline-offset-2" for="twr-01-curve">
            <span class="row-span-2 grid place-items-center size-[34px] ms-[5px] rounded-sm border-2 border-twr01-ink2 text-[0.72rem] tabular-nums text-twr01-ink2" data-n>02</span>
            <span class="min-w-0 text-sm font-semibold tracking-[0.02em]">Curvature</span>
            <span class="min-w-0 text-[0.68rem] text-twr01-ink2 [overflow-wrap:anywhere]">radius plus inset corner vignette</span>
          </label>
        </li>
        <li class="relative min-w-0">
          <input class="peer absolute top-0 left-0 size-11 m-0 opacity-0 cursor-pointer" type="checkbox" id="twr-01-bloom" checked>
          <label class="grid grid-cols-[44px_minmax(0,1fr)] items-center gap-x-3 min-h-14 cursor-pointer rounded-[3px] border border-twr01-line bg-twr01-surface py-2 pr-3 pl-2 peer-checked:border-twr01-ink2 peer-checked:[&_[data-n]]:bg-twr01-ink peer-checked:[&_[data-n]]:border-twr01-ink peer-checked:[&_[data-n]]:text-twr01-surface peer-focus-visible:outline-2 peer-focus-visible:outline-twr01-acc peer-focus-visible:outline-offset-2" for="twr-01-bloom">
            <span class="row-span-2 grid place-items-center size-[34px] ms-[5px] rounded-sm border-2 border-twr01-ink2 text-[0.72rem] tabular-nums text-twr01-ink2" data-n>03</span>
            <span class="min-w-0 text-sm font-semibold tracking-[0.02em]">Phosphor bloom</span>
            <span class="min-w-0 text-[0.68rem] text-twr01-ink2 [overflow-wrap:anywhere]">blurred duplicate, mix-blend-screen</span>
          </label>
        </li>
        <li class="relative min-w-0">
          <input class="peer absolute top-0 left-0 size-11 m-0 opacity-0 cursor-pointer" type="checkbox" id="twr-01-flick" checked>
          <label class="grid grid-cols-[44px_minmax(0,1fr)] items-center gap-x-3 min-h-14 cursor-pointer rounded-[3px] border border-twr01-line bg-twr01-surface py-2 pr-3 pl-2 peer-checked:border-twr01-ink2 peer-checked:[&_[data-n]]:bg-twr01-ink peer-checked:[&_[data-n]]:border-twr01-ink peer-checked:[&_[data-n]]:text-twr01-surface peer-focus-visible:outline-2 peer-focus-visible:outline-twr01-acc peer-focus-visible:outline-offset-2" for="twr-01-flick">
            <span class="row-span-2 grid place-items-center size-[34px] ms-[5px] rounded-sm border-2 border-twr01-ink2 text-[0.72rem] tabular-nums text-twr01-ink2" data-n>04</span>
            <span class="min-w-0 text-sm font-semibold tracking-[0.02em]">Mains flicker</span>
            <span class="min-w-0 text-[0.68rem] text-twr01-ink2 [overflow-wrap:anywhere]">6s opacity cycle, off under motion-reduce</span>
          </label>
        </li>
      </ul>
    </div>
  </div>
</section>
@import "tailwindcss";

@theme {
  --color-twr01-bg: #d9dde1;
  --color-twr01-bg: oklch(0.88 0.006 240);
  --color-twr01-surface: #f4f6f7;
  --color-twr01-surface: oklch(0.97 0.003 240);
  --color-twr01-ink: #14181b;
  --color-twr01-ink: oklch(0.19 0.008 240);
  --color-twr01-ink2: #4a545c;
  --color-twr01-ink2: oklch(0.43 0.014 240);
  --color-twr01-line: #a9b1b8;
  --color-twr01-line: oklch(0.75 0.009 240);
  --color-twr01-acc: #0f6bd8;
  --color-twr01-acc: oklch(0.52 0.19 252);
  --color-twr01-glow: #7cf6b0;
  --color-twr01-glow: oklch(0.90 0.15 158);
  --color-twr01-glowdim: #4fbf88;
  --color-twr01-glowdim: oklch(0.74 0.13 158);
  --color-twr01-tube: #04100b;
  --color-twr01-tube: oklch(0.13 0.02 158);
  --color-twr01-case: #b9beb6;
  --color-twr01-case2: #8f948c;
  --font-twr01-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  --animate-twr01-blink: twr-01-blink 1.2s steps(1, end) infinite;
  --animate-twr01-flicker: twr-01-flicker 6s ease-in-out infinite;
}
/* The layer switches and the tube geometry are plain custom properties, not tokens:
   they are read inside arbitrary utilities and never need a utility of their own. */

.twr-01 {
  --twr-01-scan: 3px;
  --twr-01-curve: 26px;
  --twr-01-scan-on: 1;
  --twr-01-curve-on: 1;
  --twr-01-bloom-on: 1;
}
/* :has() writes a custom property on the section from the state of an input nested
   inside it. A variant can select that, but it cannot perform the write — so these
   five rules are the port's irreducible CSS. */

.twr-01:has(#twr-01-scan:not(:checked)) {
  --twr-01-scan-on: 0;
}

.twr-01:has(#twr-01-curve:not(:checked)) {
  --twr-01-curve-on: 0;
  --twr-01-curve: 2px;
}

.twr-01:has(#twr-01-bloom:not(:checked)) {
  --twr-01-bloom-on: 0;
}

.twr-01:has(#twr-01-flick:not(:checked)) .twr-01__flick {
  animation: none;
  opacity: 0;
}

.twr-01:has(#twr-01-theme:checked) {
  --color-twr01-bg: #101314;
  --color-twr01-bg: oklch(0.18 0.005 200);
  --color-twr01-surface: #1b2022;
  --color-twr01-surface: oklch(0.24 0.007 200);
  --color-twr01-ink: #eef2f3;
  --color-twr01-ink: oklch(0.96 0.003 200);
  --color-twr01-ink2: #a2acb1;
  --color-twr01-ink2: oklch(0.74 0.009 200);
  --color-twr01-line: #394246;
  --color-twr01-line: oklch(0.35 0.01 200);
  --color-twr01-acc: #6fb2ff;
  --color-twr01-acc: oklch(0.76 0.13 252);
}

@keyframes twr-01-blink {
  0%,
    49% {
    opacity: 1;
  }

  50%,
    100% {
    opacity: 0;
  }
}

@keyframes twr-01-flicker {
  0%,
    96%,
    100% {
    opacity: 0;
  }

  97% {
    opacity: 0.5;
  }

  98% {
    opacity: 0.12;
  }

  99% {
    opacity: 0.38;
  }
}
/* A min-resolution query has no variant form. Unlayered rules outrank Tailwind's
   layered utilities, so this widens and softens the period with no ! prefix. */

@media (min-resolution: 1.5dppx) {
  .twr-01__scan {
    background-image: repeating-linear-gradient(
          to bottom,
          rgba(0, 0, 0, 0) 0,
          rgba(0, 0, 0, 0.62) calc(var(--twr-01-scan) * 0.5),
          rgba(0, 0, 0, 0) calc(var(--twr-01-scan) * 1.34)
        );
  }
}

@media (prefers-reduced-motion: reduce) {
  .twr-01__screen {
    transition: none;
  }
}

Setup — do this once per project

Skip this if your project already runs Tailwind v4.

1Load Tailwind
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>

Compiles in the browser — perfect for a CodePen or a quick test, not for production.

2Add the stylesheetRequiredthe @theme block is what makes the custom utilities exist
@import "tailwindcss";

@theme {
  --color-twr01-bg: #d9dde1;
  --color-twr01-bg: oklch(0.88 0.006 240);
  --color-twr01-surface: #f4f6f7;
  --color-twr01-surface: oklch(0.97 0.003 240);
  --color-twr01-ink: #14181b;
  --color-twr01-ink: oklch(0.19 0.008 240);
  --color-twr01-ink2: #4a545c;
  --color-twr01-ink2: oklch(0.43 0.014 240);
  --color-twr01-line: #a9b1b8;
  --color-twr01-line: oklch(0.75 0.009 240);
  --color-twr01-acc: #0f6bd8;
  --color-twr01-acc: oklch(0.52 0.19 252);
  --color-twr01-glow: #7cf6b0;
  --color-twr01-glow: oklch(0.90 0.15 158);
  --color-twr01-glowdim: #4fbf88;
  --color-twr01-glowdim: oklch(0.74 0.13 158);
  --color-twr01-tube: #04100b;
  --color-twr01-tube: oklch(0.13 0.02 158);
  --color-twr01-case: #b9beb6;
  --color-twr01-case2: #8f948c;
  --font-twr01-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  --animate-twr01-blink: twr-01-blink 1.2s steps(1, end) infinite;
  --animate-twr01-flicker: twr-01-flicker 6s ease-in-out infinite;
}
/* The layer switches and the tube geometry are plain custom properties, not tokens:
   they are read inside arbitrary utilities and never need a utility of their own. */

.twr-01 {
  --twr-01-scan: 3px;
  --twr-01-curve: 26px;
  --twr-01-scan-on: 1;
  --twr-01-curve-on: 1;
  --twr-01-bloom-on: 1;
}
/* :has() writes a custom property on the section from the state of an input nested
   inside it. A variant can select that, but it cannot perform the write — so these
   five rules are the port's irreducible CSS. */

.twr-01:has(#twr-01-scan:not(:checked)) {
  --twr-01-scan-on: 0;
}

.twr-01:has(#twr-01-curve:not(:checked)) {
  --twr-01-curve-on: 0;
  --twr-01-curve: 2px;
}

.twr-01:has(#twr-01-bloom:not(:checked)) {
  --twr-01-bloom-on: 0;
}

.twr-01:has(#twr-01-flick:not(:checked)) .twr-01__flick {
  animation: none;
  opacity: 0;
}

.twr-01:has(#twr-01-theme:checked) {
  --color-twr01-bg: #101314;
  --color-twr01-bg: oklch(0.18 0.005 200);
  --color-twr01-surface: #1b2022;
  --color-twr01-surface: oklch(0.24 0.007 200);
  --color-twr01-ink: #eef2f3;
  --color-twr01-ink: oklch(0.96 0.003 200);
  --color-twr01-ink2: #a2acb1;
  --color-twr01-ink2: oklch(0.74 0.009 200);
  --color-twr01-line: #394246;
  --color-twr01-line: oklch(0.35 0.01 200);
  --color-twr01-acc: #6fb2ff;
  --color-twr01-acc: oklch(0.76 0.13 252);
}

@keyframes twr-01-blink {
  0%,
    49% {
    opacity: 1;
  }

  50%,
    100% {
    opacity: 0;
  }
}

@keyframes twr-01-flicker {
  0%,
    96%,
    100% {
    opacity: 0;
  }

  97% {
    opacity: 0.5;
  }

  98% {
    opacity: 0.12;
  }

  99% {
    opacity: 0.38;
  }
}
/* A min-resolution query has no variant form. Unlayered rules outrank Tailwind's
   layered utilities, so this widens and softens the period with no ! prefix. */

@media (min-resolution: 1.5dppx) {
  .twr-01__scan {
    background-image: repeating-linear-gradient(
          to bottom,
          rgba(0, 0, 0, 0) 0,
          rgba(0, 0, 0, 0.62) calc(var(--twr-01-scan) * 0.5),
          rgba(0, 0, 0, 0) calc(var(--twr-01-scan) * 1.34)
        );
  }
}

@media (prefers-reduced-motion: reduce) {
  .twr-01__screen {
    transition: none;
  }
}
3Fonts used by this demooptional — skip if you have your own type stack
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Archivo:wght@400;600&display=swap">

What changed in the port

Which parts became utilities, which stayed CSS, and why.

  • The webfonts load as <link> tags in setup, not as an @import in the stylesheet. Tailwind emits user @import rules after its own @layer blocks, and a late @import is invalid CSS — the browser drops it and the demo silently falls back to system fonts.
  • Three of the four CRT layers are pure utilities: the bloom is blur-[3px] mix-blend-screen, the vignette is one arbitrary [background:...], and the flicker is an --animate-* token with a motion-reduce:animate-none twin typed beside it.
  • The four --*-on flags stay plain custom properties rather than @theme entries, because nothing needs a scan-on-* utility — they exist only to be read inside calc().
  • The :has() switch block is the irreducible CSS. Tailwind's has-* variant can match the selector, but the rule's job is writing a custom property on the ancestor, which no utility does.
  • The fractional-DPR scanline fix relies on layer order: the media-query rule is unlayered, so it beats the arbitrary background utility in the markup without !.
  • mix-blend-screen only composites within its stacking context, so the screen keeps isolate — without it the bloom blends against the page background.
  • Every switch keeps a 44px invisible peer checkbox under its label, so the hit target passes the pointer-size minimum while the visible control stays 34px.
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind CRT Scanlines and Screen Curvature
Source: https://codefronts.com/design-styles/tailwind-retro-ui/tailwind-crt-scanlines-and-screen-curvature/

A CRT look is not one effect, it is four: scanlines, curvature, phosphor bloom and mains flicker. Each is a separate layer with its own checkbox, so you can stack them one at a time and see what each contributes. The port is instructive because three of the four move cleanly into the class list &mdash; and the fourth, the fractional-DPR scanline fix, is the demo's whole reason for keeping a stylesheet.
## HTML
```html
<section class="twr-01 relative block w-full min-h-svh box-border bg-twr01-bg text-twr01-ink font-twr01-mono" aria-labelledby="twr-01-title">
  <h2 class="absolute size-px overflow-hidden [clip-path:inset(50%)] whitespace-nowrap" id="twr-01-title">CRT scanline and curvature layer stack</h2>

  <div class="absolute top-4 right-4 z-6">
    <input class="peer absolute inset-0 size-11 m-0 opacity-0 cursor-pointer" type="checkbox" id="twr-01-theme">
    <label class="grid place-items-center size-11 rounded-full border border-twr01-line bg-twr01-surface text-twr01-ink2 cursor-pointer peer-focus-visible:outline-2 peer-focus-visible:outline-twr01-acc peer-focus-visible:outline-offset-2" for="twr-01-theme">
      <svg class="size-5" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 4a8 8 0 0 0 0 16Z" fill="currentColor"/></svg>
      <span class="absolute size-px overflow-hidden [clip-path:inset(50%)] whitespace-nowrap">Toggle dark surround</span>
    </label>
  </div>

  <div class="grid grid-cols-[minmax(0,1fr)] gap-6 max-w-[74rem] mx-auto px-[clamp(0.85rem,0.5rem+2vw,2rem)] pt-[clamp(3.5rem,3rem+3vw,5rem)] pb-16">
    <p class="m-0 justify-self-start min-w-0 rounded-sm border border-twr01-line bg-twr01-surface px-2.5 py-1.5 text-[0.7rem] tracking-[0.16em] uppercase text-twr01-ink2">Cathode-ray tube &middot; shadow-mask display</p>

    <div class="grid grid-cols-[minmax(0,1fr)] items-start gap-5 lg:grid-cols-[minmax(0,1.55fr)_minmax(0,1fr)] lg:gap-8">
      <div class="relative min-w-0 rounded-[44px] p-[clamp(0.9rem,0.6rem+1.4vw,1.6rem)] [background:linear-gradient(180deg,var(--color-twr01-case)_0%,var(--color-twr01-case2)_100%)] [box-shadow:inset_0_2px_0_rgba(255,255,255,0.55),inset_0_-2px_0_rgba(0,0,0,0.35),0_18px_40px_-18px_rgba(0,0,0,0.65)]">
        <div class="twr-01__screen relative grid place-items-center isolate overflow-hidden aspect-4/3 rounded-[var(--twr-01-curve)] transition-[border-radius] duration-[240ms] [background:radial-gradient(120%_100%_at_50%_45%,#0a2018_0%,var(--color-twr01-tube)_72%)] [box-shadow:inset_0_0_0_2px_#06120d,inset_0_0_30px_6px_rgba(0,0,0,0.75)]">
          <div class="relative z-2 grid justify-items-center gap-2 min-w-0 p-[clamp(0.75rem,0.4rem+2vw,2rem)] text-center text-twr01-glow">
            <p class="m-0 text-[clamp(0.6rem,0.45rem+0.7vw,0.85rem)] tracking-[0.1em] text-twr01-glowdim">SYSTEM MONITOR &mdash; VT/CRT COMPOSITE</p>
            <p class="relative m-0 font-bold leading-none text-[clamp(1.6rem,0.6rem+6.5vw,4rem)] tracking-[0.14em]">PHOSPHOR<span class="absolute inset-0 grid place-items-center pointer-events-none mix-blend-screen blur-[3px] transition-opacity duration-200 [opacity:calc(0.85*var(--twr-01-bloom-on))]" aria-hidden="true">PHOSPHOR</span></p>
            <p class="m-0 text-[clamp(0.6rem,0.45rem+0.7vw,0.85rem)] tracking-[0.1em]">SCAN 15.734 kHz &nbsp; VERT 59.94 Hz</p>
            <p class="m-0 text-[clamp(0.6rem,0.45rem+0.7vw,0.85rem)] tracking-[0.1em]">MASK PITCH 0.28 mm &nbsp; DEFL 90&deg;</p>
            <p class="m-0 text-[clamp(0.6rem,0.45rem+0.7vw,0.85rem)] tracking-[0.1em] text-twr01-glowdim">READY<span class="inline-block w-[0.6em] h-[1em] ms-[0.3em] align-[-0.15em] bg-twr01-glow animate-twr01-blink motion-reduce:animate-none" aria-hidden="true"></span></p>
          </div>


          <div class="twr-01__scan absolute inset-0 z-4 pointer-events-none transition-opacity duration-200 [background-image:repeating-linear-gradient(to_bottom,rgba(0,0,0,0)_0,rgba(0,0,0,0)_calc(var(--twr-01-scan)-1px),rgba(0,0,0,0.72)_calc(var(--twr-01-scan)-1px),rgba(0,0,0,0.72)_var(--twr-01-scan))] [opacity:calc(0.55*var(--twr-01-scan-on))]" aria-hidden="true"></div>

          <div class="absolute -inset-px z-5 pointer-events-none transition-opacity duration-200 [background:radial-gradient(115%_105%_at_50%_50%,rgba(0,0,0,0)_52%,rgba(0,0,0,0.55)_84%,rgba(0,0,0,0.9)_100%),linear-gradient(90deg,rgba(255,255,255,0.05),rgba(255,255,255,0)_18%_82%,rgba(255,255,255,0.05))] [opacity:var(--twr-01-curve-on)]" aria-hidden="true"></div>

          <div class="twr-01__flick absolute inset-0 z-6 pointer-events-none opacity-0 bg-[rgba(120,255,190,0.05)] animate-twr01-flicker motion-reduce:animate-none" aria-hidden="true"></div>
        </div>

        <div class="flex items-center justify-between gap-3 min-w-0 pt-3 px-1.5 pb-0.5">
          <span class="min-w-0 text-[0.62rem] tracking-[0.22em] text-[#3c4038] [text-shadow:0_1px_0_rgba(255,255,255,0.45)]">TRINITRON&nbsp;/&nbsp;14"</span>
          <span class="flex-none size-[9px] rounded-full bg-[#63f0a0] [box-shadow:0_0_8px_2px_rgba(99,240,160,0.7),inset_0_0_2px_#0a3a22]" aria-hidden="true"></span>
        </div>
      </div>

      <ul class="list-none m-0 p-0 min-w-0 grid grid-cols-[minmax(0,1fr)] content-start gap-2 lg:gap-2.5">
        <li class="relative min-w-0">
          <input class="peer absolute top-0 left-0 size-11 m-0 opacity-0 cursor-pointer" type="checkbox" id="twr-01-scan" checked>
          <label class="grid grid-cols-[44px_minmax(0,1fr)] items-center gap-x-3 min-h-14 cursor-pointer rounded-[3px] border border-twr01-line bg-twr01-surface py-2 pr-3 pl-2 peer-checked:border-twr01-ink2 peer-checked:[&_[data-n]]:bg-twr01-ink peer-checked:[&_[data-n]]:border-twr01-ink peer-checked:[&_[data-n]]:text-twr01-surface peer-focus-visible:outline-2 peer-focus-visible:outline-twr01-acc peer-focus-visible:outline-offset-2" for="twr-01-scan">
            <span class="row-span-2 grid place-items-center size-[34px] ms-[5px] rounded-sm border-2 border-twr01-ink2 text-[0.72rem] tabular-nums text-twr01-ink2" data-n>01</span>
            <span class="min-w-0 text-sm font-semibold tracking-[0.02em]">Scanlines</span>
            <span class="min-w-0 text-[0.68rem] text-twr01-ink2 [overflow-wrap:anywhere]">repeating gradient, 3px period</span>
          </label>
        </li>
        <li class="relative min-w-0">
          <input class="peer absolute top-0 left-0 size-11 m-0 opacity-0 cursor-pointer" type="checkbox" id="twr-01-curve" checked>
          <label class="grid grid-cols-[44px_minmax(0,1fr)] items-center gap-x-3 min-h-14 cursor-pointer rounded-[3px] border border-twr01-line bg-twr01-surface py-2 pr-3 pl-2 peer-checked:border-twr01-ink2 peer-checked:[&_[data-n]]:bg-twr01-ink peer-checked:[&_[data-n]]:border-twr01-ink peer-checked:[&_[data-n]]:text-twr01-surface peer-focus-visible:outline-2 peer-focus-visible:outline-twr01-acc peer-focus-visible:outline-offset-2" for="twr-01-curve">
            <span class="row-span-2 grid place-items-center size-[34px] ms-[5px] rounded-sm border-2 border-twr01-ink2 text-[0.72rem] tabular-nums text-twr01-ink2" data-n>02</span>
            <span class="min-w-0 text-sm font-semibold tracking-[0.02em]">Curvature</span>
            <span class="min-w-0 text-[0.68rem] text-twr01-ink2 [overflow-wrap:anywhere]">radius plus inset corner vignette</span>
          </label>
        </li>
        <li class="relative min-w-0">
          <input class="peer absolute top-0 left-0 size-11 m-0 opacity-0 cursor-pointer" type="checkbox" id="twr-01-bloom" checked>
          <label class="grid grid-cols-[44px_minmax(0,1fr)] items-center gap-x-3 min-h-14 cursor-pointer rounded-[3px] border border-twr01-line bg-twr01-surface py-2 pr-3 pl-2 peer-checked:border-twr01-ink2 peer-checked:[&_[data-n]]:bg-twr01-ink peer-checked:[&_[data-n]]:border-twr01-ink peer-checked:[&_[data-n]]:text-twr01-surface peer-focus-visible:outline-2 peer-focus-visible:outline-twr01-acc peer-focus-visible:outline-offset-2" for="twr-01-bloom">
            <span class="row-span-2 grid place-items-center size-[34px] ms-[5px] rounded-sm border-2 border-twr01-ink2 text-[0.72rem] tabular-nums text-twr01-ink2" data-n>03</span>
            <span class="min-w-0 text-sm font-semibold tracking-[0.02em]">Phosphor bloom</span>
            <span class="min-w-0 text-[0.68rem] text-twr01-ink2 [overflow-wrap:anywhere]">blurred duplicate, mix-blend-screen</span>
          </label>
        </li>
        <li class="relative min-w-0">
          <input class="peer absolute top-0 left-0 size-11 m-0 opacity-0 cursor-pointer" type="checkbox" id="twr-01-flick" checked>
          <label class="grid grid-cols-[44px_minmax(0,1fr)] items-center gap-x-3 min-h-14 cursor-pointer rounded-[3px] border border-twr01-line bg-twr01-surface py-2 pr-3 pl-2 peer-checked:border-twr01-ink2 peer-checked:[&_[data-n]]:bg-twr01-ink peer-checked:[&_[data-n]]:border-twr01-ink peer-checked:[&_[data-n]]:text-twr01-surface peer-focus-visible:outline-2 peer-focus-visible:outline-twr01-acc peer-focus-visible:outline-offset-2" for="twr-01-flick">
            <span class="row-span-2 grid place-items-center size-[34px] ms-[5px] rounded-sm border-2 border-twr01-ink2 text-[0.72rem] tabular-nums text-twr01-ink2" data-n>04</span>
            <span class="min-w-0 text-sm font-semibold tracking-[0.02em]">Mains flicker</span>
            <span class="min-w-0 text-[0.68rem] text-twr01-ink2 [overflow-wrap:anywhere]">6s opacity cycle, off under motion-reduce</span>
          </label>
        </li>
      </ul>
    </div>
  </div>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --color-twr01-bg: #d9dde1;
  --color-twr01-bg: oklch(0.88 0.006 240);
  --color-twr01-surface: #f4f6f7;
  --color-twr01-surface: oklch(0.97 0.003 240);
  --color-twr01-ink: #14181b;
  --color-twr01-ink: oklch(0.19 0.008 240);
  --color-twr01-ink2: #4a545c;
  --color-twr01-ink2: oklch(0.43 0.014 240);
  --color-twr01-line: #a9b1b8;
  --color-twr01-line: oklch(0.75 0.009 240);
  --color-twr01-acc: #0f6bd8;
  --color-twr01-acc: oklch(0.52 0.19 252);
  --color-twr01-glow: #7cf6b0;
  --color-twr01-glow: oklch(0.90 0.15 158);
  --color-twr01-glowdim: #4fbf88;
  --color-twr01-glowdim: oklch(0.74 0.13 158);
  --color-twr01-tube: #04100b;
  --color-twr01-tube: oklch(0.13 0.02 158);
  --color-twr01-case: #b9beb6;
  --color-twr01-case2: #8f948c;
  --font-twr01-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  --animate-twr01-blink: twr-01-blink 1.2s steps(1, end) infinite;
  --animate-twr01-flicker: twr-01-flicker 6s ease-in-out infinite;
}
/* The layer switches and the tube geometry are plain custom properties, not tokens:
   they are read inside arbitrary utilities and never need a utility of their own. */

.twr-01 {
  --twr-01-scan: 3px;
  --twr-01-curve: 26px;
  --twr-01-scan-on: 1;
  --twr-01-curve-on: 1;
  --twr-01-bloom-on: 1;
}
/* :has() writes a custom property on the section from the state of an input nested
   inside it. A variant can select that, but it cannot perform the write — so these
   five rules are the port's irreducible CSS. */

.twr-01:has(#twr-01-scan:not(:checked)) {
  --twr-01-scan-on: 0;
}

.twr-01:has(#twr-01-curve:not(:checked)) {
  --twr-01-curve-on: 0;
  --twr-01-curve: 2px;
}

.twr-01:has(#twr-01-bloom:not(:checked)) {
  --twr-01-bloom-on: 0;
}

.twr-01:has(#twr-01-flick:not(:checked)) .twr-01__flick {
  animation: none;
  opacity: 0;
}

.twr-01:has(#twr-01-theme:checked) {
  --color-twr01-bg: #101314;
  --color-twr01-bg: oklch(0.18 0.005 200);
  --color-twr01-surface: #1b2022;
  --color-twr01-surface: oklch(0.24 0.007 200);
  --color-twr01-ink: #eef2f3;
  --color-twr01-ink: oklch(0.96 0.003 200);
  --color-twr01-ink2: #a2acb1;
  --color-twr01-ink2: oklch(0.74 0.009 200);
  --color-twr01-line: #394246;
  --color-twr01-line: oklch(0.35 0.01 200);
  --color-twr01-acc: #6fb2ff;
  --color-twr01-acc: oklch(0.76 0.13 252);
}

@keyframes twr-01-blink {
  0%,
    49% {
    opacity: 1;
  }

  50%,
    100% {
    opacity: 0;
  }
}

@keyframes twr-01-flicker {
  0%,
    96%,
    100% {
    opacity: 0;
  }

  97% {
    opacity: 0.5;
  }

  98% {
    opacity: 0.12;
  }

  99% {
    opacity: 0.38;
  }
}
/* A min-resolution query has no variant form. Unlayered rules outrank Tailwind's
   layered utilities, so this widens and softens the period with no ! prefix. */

@media (min-resolution: 1.5dppx) {
  .twr-01__scan {
    background-image: repeating-linear-gradient(
          to bottom,
          rgba(0, 0, 0, 0) 0,
          rgba(0, 0, 0, 0.62) calc(var(--twr-01-scan) * 0.5),
          rgba(0, 0, 0, 0) calc(var(--twr-01-scan) * 1.34)
        );
  }
}

@media (prefers-reduced-motion: reduce) {
  .twr-01__screen {
    transition: none;
  }
}
```

How this works

Tokens first, because utilities come from them. In v4 there is no config file: @theme is the theme, and every entry generates utilities in the namespace its prefix implies. A --color-* entry gives you text, background, border and ring utilities at once, which is what a themed monitor needs.

@theme {
  --color-twr01-glow: oklch(0.88 0.16 158);
  --color-twr01-tube: oklch(0.16 0.03 158);
  --font-twr01-mono: 'JetBrains Mono', ui-monospace, monospace;
  --animate-twr01-flicker: twr-01-flicker 6s ease-in-out infinite;
}

Scanlines are a gradient with a period, not a texture. A repeating gradient with a transparent stop and a dark stop, repeated every 3px, gives a line and a gap. The period is the whole design decision: 2px reads as a grey wash, 4px as a venetian blind, 3px as a tube. It goes in the class list as an arbitrary background-image, and the underscore escape carries the multi-stop syntax through:

class="absolute inset-0 z-4 pointer-events-none
       [background-image:repeating-linear-gradient(to_bottom,transparent_0,transparent_calc(var(--twr-01-scan)-1px),rgba(0,0,0,0.72)_calc(var(--twr-01-scan)-1px),rgba(0,0,0,0.72)_var(--twr-01-scan))]
       [opacity:calc(0.55*var(--twr-01-scan-on))]"

Curvature is two effects pretending to be one. The glass edge is a large radius on the bezel with the screen clipped inside it (rounded-[26px] overflow-hidden); the optical falloff is a separate inset radial gradient that darkens the corners. Radius alone reads as a modern device; vignette alone reads as a vignetted flat panel. The pair reads as a tube, and neither needs a filter.

Bloom is the part utilities do best. The bloom layer is a blurred copy of the same text composited with mix-blend-screen. Screen never darkens, so black stays black and only lit phosphor spreads — the physical behaviour. Both halves are stock utilities (blur-[3px] mix-blend-screen), which means the entire effect is visible in the markup rather than buried in a rule.

The switches are :has(), and that is the one thing utilities cannot do. A variant can ask about an element's own state or its ancestor's, but the four flags have to be set on the section from the state of four inputs nested inside it. So the stylesheet keeps four one-line rules that set --twr-01-scan-on: 0 and friends, and every layer reads them through an arbitrary calc() opacity. Tailwind's has-* variant can express the selector, but not the custom-property write it needs.

The trap: 1px scanlines alias on fractional device pixel ratios. On a 1.25× or 1.5× display the browser cannot map a 1px stop to a whole device pixel, so the line beats against the pixel grid and you get crawling moiré that reads as a rendering bug. The fix is a @media (min-resolution: 1.5dppx) block that widens the period and softens the stop — and a feature query is not a variant, so it stays in CSS. Because unlayered rules outrank Tailwind's layered utilities, the plain .twr-01__scan selector inside that media query overrides the arbitrary background in the markup with no ! prefix needed.

Contrast is measured through the overlay. Scanlines cut effective contrast on everything beneath them, so the glyph lightness is lifted until the composited result still clears 4.5:1 — not the bare text colour.

Make it yours

  • Change --twr-01-scan from 3px to 2px for a fine shadow mask or 5px for an early monochrome monitor with visible line structure; every layer reads the same variable.
  • Tint the phosphor by editing --color-twr01-glow in @theme — text, bloom and LED follow, because they are the same token.
  • Flatten the tube by swapping rounded-[26px] for rounded-lg and dropping the vignette to opacity-25; that is roughly a late-90s flat-square CRT.
  • Retime the flicker in the --animate-twr01-flicker entry rather than on the element, so the reduced-motion fallback stays a single motion-reduce:animate-none.
  • Raise the bloom with blur-[6px] for a worn tube; past 8px it stops reading as bloom and starts reading as defocus.
  • Delete the four :has() rules and the layers hard-enable at full strength — the effects never depended on the switches.

Gotchas — read before shipping

  • Arbitrary values cannot contain spaces. Use the underscore escape inside gradients (transparent_0); a literal space silently ends the class and the whole layer disappears.
  • A @media (min-resolution: ...) rule has no variant form, so the scanline fix cannot move into the markup. Ship it or the effect looks broken on most laptops.
  • Class strings must be statically visible to the compiler. Building [opacity:calc(0.55*var(--x))] from a template expression produces nothing — hard-code the class and vary the variable.
  • Never animate filter: blur(). Animate the opacity of an already-blurred layer; a full-screen animated blur is a per-frame repaint of the whole compositing tree.
  • mix-blend-screen needs an isolate ancestor or it blends against the page behind the section rather than the tube.
  • A CRT frame reads as hacker-adjacent, and that connotation is loaded. Reach for it on a game, a music tool or a themed page — not on a checkout.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

Tailwind v4 itself targets Chrome 111+, Safari 16.4+ and Firefox 128+, because the generated sheet uses cascade layers, @property and color-mix(). That is a higher floor than the hand-written CSS version. The CRT mechanism underneath — repeating gradients, border-radius, filter, mix-blend-mode — has been Baseline for over a decade, and engines without :has() simply show every layer enabled, which is the intended full effect.

Techniques used in this demo

Utilitiesfirst-class utility or variant

Plain CSSno Tailwind surface

Search CodeFronts

Loading…