# CRT Overlay

> Scanlines, vignette, flicker and a power-on animation over anything.

Category: Effects. Docs: https://neonkit.heyaadi.com/components/crt-overlay

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/crt-overlay.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/crt-overlay`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { CrtOverlay } from "@/components/ui/crt-overlay"

<CrtOverlay curvature className="aspect-[4/3]">
  <p>Insert coin</p>
</CrtOverlay>
```

## Examples

### Arcade monitor

The screen powers on when it mounts. Press the power key: the picture collapses to a white line, then a dot that fades. Press again and it opens back up.

```tsx
"use client";

import * as React from "react";
import { Power } from "@/components/ui/icons";
import { CrtOverlay } from "@/components/ui/crt-overlay";
import { cn } from "@/lib/utils";

const scores = [
  { rank: "1st", name: "AOK", score: "048 220" },
  { rank: "2nd", name: "MRA", score: "041 905" },
  { rank: "3rd", name: "JNO", score: "039 410" },
  { rank: "4th", name: "TKS", score: "031 775" },
  { rank: "5th", name: "ELI", score: "027 060" },
];

export default function CrtOverlayDemo() {
  const [on, setOn] = React.useState(true);

  return (
    <div className="w-full max-w-md rounded-[30px] [--neon:var(--neon-accent,34_211_238)] border border-white/10 bg-[linear-gradient(180deg,#17181f,#0b0c11)] p-4 pb-3 shadow-[0_30px_60px_-30px_rgb(0_0_0/0.9),inset_0_1px_0_rgb(255_255_255/0.08)]">
      <CrtOverlay on={on} animateOnMount curvature className="aspect-[4/3]">
        <div className="flex h-full flex-col items-center justify-center px-6 font-mono uppercase sm:px-8">
          <p className="text-[11px] tracking-[0.3em] text-[rgb(var(--neon))] sm:text-[13px]">High scores</p>
          <ol className="mt-3 w-full max-w-60 space-y-1 text-[11px] tabular-nums tracking-[0.12em] text-white/90 sm:mt-5 sm:space-y-1.5 sm:text-[13px]">
            {scores.map((row, i) => (
              <li key={row.rank} className={cn("flex justify-between", i === 0 && "text-[rgb(var(--neon))]")}>
                <span className="w-10">{row.rank}</span>
                <span>{row.name}</span>
                <span>{row.score}</span>
              </li>
            ))}
          </ol>
          <p className="mt-4 text-[10px] tracking-[0.3em] text-white/90 motion-safe:animate-pulse motion-safe:[animation-duration:1.4s] sm:mt-6 sm:text-[12px]">
            Insert coin
          </p>
          <p className="absolute bottom-3 left-6 text-[9px] tracking-[0.2em] text-white/50 sm:bottom-4 sm:text-[10px]">Credit 0</p>
        </div>
      </CrtOverlay>

      <div className="mt-3 flex items-center justify-between px-2">
        <span className="text-[12px] font-medium text-white/55">NK-14 colour monitor</span>
        <div className="flex items-center gap-3">
          <span
            aria-hidden
            className={cn(
              "size-1.5 rounded-full transition-[background-color,box-shadow] duration-300",
              on
                ? "bg-[rgb(var(--neon))] shadow-[0_0_6px_rgb(var(--neon)),0_0_14px_rgb(var(--neon)/0.6)]"
                : "bg-[rgb(var(--neon)/0.15)]",
            )}
          />
          <button
            type="button"
            aria-label="Power"
            aria-pressed={on}
            onClick={() => setOn((value) => !value)}
            className="grid size-8 cursor-pointer place-items-center rounded-full border border-white/10 bg-[linear-gradient(180deg,#22232b,#121319)] text-white/60 shadow-[inset_0_1px_0_rgb(255_255_255/0.08),0_2px_4px_rgb(0_0_0/0.5)] outline-none transition-[transform,color] duration-150 hover:text-white/90 active:scale-[0.94] focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0b0c11]"
          >
            <Power className="size-3.5" />
          </button>
        </div>
      </div>
    </div>
  );
}
```

### Monochrome phosphor

phosphor tints everything in one color, like an amber or green terminal.

```tsx
import { CrtOverlay } from "@/components/ui/crt-overlay";

const log = [
  { time: "23:58:02", text: "relay-3   heartbeat ok   12ms" },
  { time: "23:58:32", text: "relay-3   heartbeat ok   11ms" },
  { time: "23:59:02", text: "relay-7   link down, retrying" },
  { time: "23:59:04", text: "relay-7   link up        38ms" },
];

export default function CrtOverlayPhosphor() {
  return (
    <CrtOverlay phosphor color="amber" className="w-full max-w-lg">
      <div className="p-5 font-mono text-[11px] leading-5 text-white sm:p-6 sm:text-[13px] sm:leading-6">
        <p>NK/OS 2.1 (tty1)</p>
        <p className="text-white/60">Last login: Sat Oct 10 23:41 on tty1</p>
        <p className="mt-3">
          <span className="text-white/60">operator@relay $</span> tail -n 4 /var/log/relay
        </p>
        {log.map((line) => (
          <p key={line.time} className="whitespace-pre">
            <span className="text-white/60">{line.time}</span> {line.text}
          </p>
        ))}
        <p className="mt-3">
          <span className="text-white/60">operator@relay $</span>{" "}
          <span aria-hidden className="inline-block h-4 w-2 translate-y-0.5 bg-white motion-safe:animate-pulse" />
        </p>
      </div>
    </CrtOverlay>
  );
}
```

### Effects

Each effect can be switched off on its own.

```tsx
"use client";

import * as React from "react";
import { CrtOverlay } from "@/components/ui/crt-overlay";
import { Switch } from "@/components/ui/switch";

const bars = ["#c0c0c0", "#c0c000", "#00c0c0", "#00c000", "#c000c0", "#c00000", "#0000c0"];
const effects = [
  { key: "scanlines", label: "Scanlines" },
  { key: "flicker", label: "Flicker" },
  { key: "chromatic", label: "Chromatic" },
  { key: "glow", label: "Glow" },
  { key: "curvature", label: "Curvature" },
] as const;
type Effect = (typeof effects)[number]["key"];

export default function CrtOverlayTestCard() {
  const [enabled, setEnabled] = React.useState<Record<Effect, boolean>>({
    scanlines: true,
    flicker: true,
    chromatic: true,
    glow: true,
    curvature: true,
  });

  return (
    <div className="flex w-full max-w-xl flex-col gap-5">
      <CrtOverlay {...enabled} className="aspect-video">
        <div className="flex h-full flex-col">
          <div className="flex flex-[3]">
            {bars.map((bar) => (
              <div key={bar} className="flex-1" style={{ background: bar }} />
            ))}
          </div>
          <div className="relative flex flex-1 items-center justify-center bg-[#101012]">
            <p className="rounded-[3px] bg-black px-3 py-1 font-mono text-sm uppercase tracking-[0.3em] text-white">
              NK-TV · Ch 12
            </p>
          </div>
        </div>
      </CrtOverlay>

      <div className="flex flex-wrap gap-x-5 gap-y-3">
        {effects.map(({ key, label }) => (
          <Switch
            key={key}
            size="sm"
            label={label}
            checked={enabled[key]}
            onCheckedChange={(checked) => setEnabled((current) => ({ ...current, [key]: checked }))}
          />
        ))}
      </div>
    </div>
  );
}
```

### Anything inside

The overlays ignore the pointer, so forms and buttons on the screen still work.

```tsx
"use client";

import * as React from "react";
import { Button } from "@/components/ui/button";
import { CrtOverlay } from "@/components/ui/crt-overlay";
import { Input } from "@/components/ui/input";

export default function CrtOverlaySignIn() {
  const [handle, setHandle] = React.useState("");
  const [state, setState] = React.useState<"idle" | "dialing" | "connected">("idle");

  React.useEffect(() => {
    if (state !== "dialing") return;
    const timeout = setTimeout(() => setState("connected"), 1400);
    return () => clearTimeout(timeout);
  }, [state]);

  return (
    <CrtOverlay curvature className="w-full max-w-sm">
      <form
        onSubmit={(event) => {
          event.preventDefault();
          setState("dialing");
        }}
        className="flex flex-col gap-4 p-8 pb-9"
      >
        <div>
          <h3 className="text-lg font-medium text-[rgb(var(--neon))]">Night Owl BBS</h3>
          <p className="mt-1 text-sm text-white/55" aria-live="polite">
            {state === "idle" && "Node 2 of 4 is free. 14,400 baud."}
            {state === "dialing" && "Dialing 555-0142…"}
            {state === "connected" && `Connected to node 2. Welcome back, ${handle.trim() || "caller"}.`}
          </p>
        </div>
        <Input
          label="Handle"
          placeholder="your handle"
          autoComplete="username"
          required
          value={handle}
          onChange={(event) => setHandle(event.target.value)}
        />
        <Input label="Password" type="password" placeholder="••••••••" autoComplete="current-password" required />
        <Button type="submit" variant="solid" className="mt-1" disabled={state === "dialing"}>
          {state === "dialing" ? "Dialing…" : state === "connected" ? "Hang up and redial" : "Dial in"}
        </Button>
      </form>
    </CrtOverlay>
  );
}
```

## API reference

### CrtOverlay

Also accepts div attributes, and the ref points at the screen. className and style go to the screen; give it a size or aspect ratio when the content doesn't set one.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `on` | `boolean` | `true` | Power. Off collapses the picture to a line and a dot; on reverses it. |
| `animateOnMount` | `boolean` | `false` | Play the power-on animation when the screen first mounts lit. |
| `onPowerComplete` | `(on: boolean) => void` | — | Called when a power-on or power-off animation finishes. |
| `scanlines` | `boolean` | `true` | Horizontal scanlines and a faint aperture grille. |
| `flicker` | `boolean` | `true` | A slow refresh bar and the odd faint brightness dip. |
| `chromatic` | `boolean` | `true` | Red and blue fringes on text, like a slightly misconverged tube. |
| `glow` | `boolean` | `true` | Text blooms in the phosphor color. |
| `curvature` | `boolean` | `false` | Rounded, bulging glass with darker edges and a curved reflection. |
| `phosphor` | `boolean` | `false` | Tints the whole picture in the phosphor color. |
| `color` | `NeonColor` | `inherited` | Phosphor color for the glow, the tint and the light the screen throws. |

## Accessibility

- Scanlines, glass and glow are aria-hidden and ignore the pointer; the content stays readable and interactive.
- While off, the content is inert and hidden from assistive tech, and it comes back when the screen powers on.
- Flicker is a slow refresh bar plus a few faint dips in brightness every few seconds, never a strobe.
- With reduced motion there's no flicker or refresh bar, and power on and off become a short fade.
- Chromatic fringes and glow are text-shadows that the content inherits; text that sets its own text-shadow keeps it.

## Source

`components/ui/crt-overlay.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

// Power on: a dot stretches into a bright line, the line opens into the
// picture with a little vertical overshoot. Power off runs it backwards and
// leaves a dot of phosphor that fades on its own.
const css = `
@keyframes nk-crt-overlay-on {
  0% { transform: scale(0, 0.008); opacity: 0; }
  8% { transform: scale(0.06, 0.008); opacity: 1; }
  34% { transform: scale(1, 0.008); }
  62% { transform: scale(1, 1.035); }
  76% { transform: scale(1, 0.99); }
  100% { transform: scale(1, 1); opacity: 1; }
}
@keyframes nk-crt-overlay-off {
  0% { transform: scale(1, 1); opacity: 1; }
  42% { transform: scale(1, 0.008); }
  76% { transform: scale(0.1, 0.008); opacity: 1; }
  100% { transform: scale(0, 0.008); opacity: 0; }
}
@keyframes nk-crt-overlay-flash-on { 0%, 34% { opacity: 1; } 100% { opacity: 0; } }
@keyframes nk-crt-overlay-flash-off { 0% { opacity: 0; } 38%, 100% { opacity: 1; } }
@keyframes nk-crt-overlay-afterglow { 0%, 30% { opacity: 0; transform: scale(1); } 36% { opacity: 1; transform: scale(1); } 100% { opacity: 0; transform: scale(0.3); } }
@keyframes nk-crt-overlay-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes nk-crt-overlay-flicker { 0%, 100% { opacity: 0; } 6% { opacity: 0.05; } 8% { opacity: 0; } 41% { opacity: 0.03; } 43% { opacity: 0; } 77% { opacity: 0.06; } 78.5% { opacity: 0; } }
@keyframes nk-crt-overlay-roll { from { transform: translateY(-100%); } to { transform: translateY(400%); } }
.nk-crt-overlay-screen[data-phase="booting"] { animation: nk-crt-overlay-on 900ms cubic-bezier(0.23, 1, 0.32, 1) both; }
.nk-crt-overlay-screen[data-phase="shutting"] { animation: nk-crt-overlay-off 520ms cubic-bezier(0.55, 0, 0.75, 0.2) both; }
.nk-crt-overlay-screen[data-phase="off"] { opacity: 0; }
.nk-crt-overlay-screen[data-phase="booting"] .nk-crt-overlay-flash { animation: nk-crt-overlay-flash-on 900ms ease-out both; }
.nk-crt-overlay-screen[data-phase="shutting"] .nk-crt-overlay-flash { animation: nk-crt-overlay-flash-off 520ms ease-out both; }
.nk-crt-overlay-afterglow { animation: nk-crt-overlay-afterglow 1500ms ease-out both; }
.nk-crt-overlay-flicker { animation: nk-crt-overlay-flicker 3.7s linear infinite; }
.nk-crt-overlay-roll { animation: nk-crt-overlay-roll 7s linear infinite; }
@media (prefers-reduced-motion: reduce) {
  .nk-crt-overlay-screen[data-phase="booting"] { animation: nk-crt-overlay-fade 160ms ease-out both; }
  .nk-crt-overlay-screen[data-phase="shutting"] { animation: nk-crt-overlay-fade 120ms ease-out reverse both; }
  .nk-crt-overlay-screen[data-phase] .nk-crt-overlay-flash,
  .nk-crt-overlay-afterglow,
  .nk-crt-overlay-flicker,
  .nk-crt-overlay-roll { animation: none; opacity: 0; }
}
`;

type Phase = "on" | "off" | "booting" | "shutting";

// Fallbacks in case animationend never arrives (hidden tab, display: none).
const durations = { booting: 950, shutting: 1600 };

export interface CrtOverlayProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Phosphor color for the glow and the tint. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  /** Power. Turning it off collapses the picture to a line and a dot; on reverses it. */
  on?: boolean;
  /** Play the power-on animation when the screen first mounts lit. */
  animateOnMount?: boolean;
  /** Horizontal scanlines and a faint aperture grille. */
  scanlines?: boolean;
  /** A slow refresh bar and the odd brightness dip. */
  flicker?: boolean;
  /** Red and blue fringes on text, like a slightly misconverged tube. */
  chromatic?: boolean;
  /** Text blooms in the phosphor color. */
  glow?: boolean;
  /** Rounded, bulging glass with darker edges. */
  curvature?: boolean;
  /** Tints the whole picture in the phosphor color, like a monochrome monitor. */
  phosphor?: boolean;
  /** Called when a power-on or power-off animation finishes. */
  onPowerComplete?: (on: boolean) => void;
}

const CrtOverlay = React.forwardRef<HTMLDivElement, CrtOverlayProps>(
  (
    {
      className,
      style,
      color,
      on = true,
      animateOnMount = false,
      scanlines = true,
      flicker = true,
      chromatic = true,
      glow = true,
      curvature = false,
      phosphor = false,
      onPowerComplete,
      children,
      ...props
    },
    ref,
  ) => {
    const [phase, setPhase] = React.useState<Phase>(() =>
      on ? (animateOnMount ? "booting" : "on") : "off",
    );
    const [previous, setPrevious] = React.useState(on);
    // Each power-off mounts a fresh afterglow dot, so it plays once per shutdown.
    const [shutdowns, setShutdowns] = React.useState(0);
    if (previous !== on) {
      setPrevious(on);
      setPhase(on ? "booting" : "shutting");
      if (!on) setShutdowns((count) => count + 1);
    }

    const contentRef = React.useRef<HTMLDivElement>(null);
    const onComplete = React.useRef(onPowerComplete);
    onComplete.current = onPowerComplete;

    const finish = React.useCallback((from: Phase) => {
      setPhase((current) => (current === from ? (from === "booting" ? "on" : "off") : current));
    }, []);

    React.useEffect(() => {
      if (phase !== "booting" && phase !== "shutting") return;
      const timeout = setTimeout(() => finish(phase), durations[phase]);
      return () => clearTimeout(timeout);
    }, [phase, finish]);

    const settled = React.useRef(phase);
    React.useEffect(() => {
      const from = settled.current;
      settled.current = phase;
      if ((from === "booting" && phase === "on") || (from === "shutting" && phase === "off")) {
        onComplete.current?.(phase === "on");
      }
    }, [phase]);

    // A dark tube shows nothing, so its content shouldn't take focus or clicks.
    React.useEffect(() => {
      if (contentRef.current) contentRef.current.inert = !on;
    }, [on]);

    const lit = on && phase !== "off";
    const textShadow =
      [
        chromatic && "-0.6px 0 rgb(255 40 90 / 0.5), 0.6px 0 rgb(40 190 255 / 0.5)",
        glow && "0 0 1px rgb(255 255 255 / calc(0.4 * var(--neon-glow, 1))), 0 0 12px rgb(var(--neon) / calc(0.6 * var(--neon-glow, 1)))",
      ]
        .filter(Boolean)
        .join(", ") || undefined;

    return (
      <div
        ref={ref}
        data-state={on ? "on" : "off"}
        style={neonVars(color, style)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "relative isolate overflow-hidden bg-[#040507] text-white/90",
          "transition-shadow duration-700 ease-out",
          curvature ? "rounded-[2.25rem/1.75rem]" : "rounded-xl",
          lit
            ? "shadow-[0_0_0_1px_rgb(255_255_255/0.06),0_0_70px_-18px_rgb(var(--neon)/CALC(0.55*var(--neon-glow,1)))]"
            : "shadow-[0_0_0_1px_rgb(255_255_255/0.06),0_0_70px_-18px_rgb(var(--neon)/0)]",
          className,
        )}
        {...props}
      >
        <style>{css}</style>
        <div
          data-phase={phase}
          onAnimationEnd={(event) => {
            if (event.target === event.currentTarget) finish(phase);
          }}
          className="nk-crt-overlay-screen relative h-full"
        >
          {/* The raster: a lit tube glows faintly even where the picture is black */}
          <div
            aria-hidden
            className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgb(var(--neon)/CALC(0.1*var(--neon-glow,1))),rgb(var(--neon)/CALC(0.035*var(--neon-glow,1)))_55%,transparent_85%)]"
          />
          <div
            ref={contentRef}
            aria-hidden={on ? undefined : true}
            style={{ textShadow }}
            className={cn("relative h-full", phase === "off" && "invisible")}
          >
            {children}
          </div>
          <div aria-hidden className="pointer-events-none absolute inset-0">
            {phosphor && (
              <div className="absolute inset-0 bg-[color-mix(in_srgb,rgb(var(--neon))_70%,white)] mix-blend-multiply" />
            )}
            {scanlines && (
              <>
                <div className="absolute inset-0 bg-[repeating-linear-gradient(to_bottom,rgb(0_0_0/0.32)_0_1px,transparent_1px_3px)]" />
                <div className="absolute inset-0 opacity-[0.45] mix-blend-multiply bg-[repeating-linear-gradient(to_right,rgb(255_190_190)_0_1px,rgb(190_255_190)_1px_2px,rgb(190_200_255)_2px_3px)]" />
              </>
            )}
            {flicker && (
              <>
                <div className="nk-crt-overlay-roll absolute inset-x-0 top-0 h-1/4 bg-[linear-gradient(to_bottom,transparent,rgb(255_255_255/0.045),transparent)]" />
                <div className="nk-crt-overlay-flicker absolute inset-0 bg-black opacity-0" />
              </>
            )}
            <div className="nk-crt-overlay-flash absolute inset-0 bg-[color-mix(in_srgb,rgb(var(--neon))_20%,white)] opacity-0" />
          </div>
        </div>
        {/* Glass: unaffected by power, so a dark tube still reads as a screen */}
        <div aria-hidden className="pointer-events-none absolute inset-0 rounded-[inherit]">
          {shutdowns > 0 && !on && (
            <div
              key={shutdowns}
              className="nk-crt-overlay-afterglow absolute left-1/2 top-1/2 -ml-1 -mt-1 size-2 rounded-full bg-[color-mix(in_srgb,rgb(var(--neon))_25%,white)] opacity-0 shadow-[0_0_8px_2px_rgb(var(--neon)/var(--neon-glow,1)),0_0_28px_6px_rgb(var(--neon)/CALC(0.6*var(--neon-glow,1)))]"
            />
          )}
          <div
            className={cn(
              "absolute inset-0 rounded-[inherit]",
              curvature
                ? "bg-[radial-gradient(ellipse_at_center,transparent_48%,rgb(0_0_0/0.55)_100%)] shadow-[inset_0_0_50px_12px_rgb(0_0_0/0.75),inset_0_0_3px_1px_rgb(255_255_255/0.08)]"
                : "bg-[radial-gradient(ellipse_at_center,transparent_62%,rgb(0_0_0/0.4)_100%)] shadow-[inset_0_0_28px_rgb(0_0_0/0.6)]",
            )}
          />
          <div
            className={cn(
              "absolute inset-0 rounded-[inherit]",
              curvature
                ? "bg-[radial-gradient(ellipse_70%_45%_at_32%_8%,rgb(255_255_255/0.09),transparent_70%)]"
                : "bg-[linear-gradient(165deg,rgb(255_255_255/0.05),transparent_38%)]",
            )}
          />
        </div>
      </div>
    );
  },
);
CrtOverlay.displayName = "CrtOverlay";

export { CrtOverlay };
```
