Phosphor
A themed panel styled as a monochrome CRT monitor: a dark vignetted screen with glowing selectable monospace text, fine scanlines, a slow refresh-bar roll, and a blinking block caret. The built-in switcher recoats the tube between amber, green, and paper-white phosphor. Pure CSS; reduced motion holds a steady glow.
cssfree
"use client";
import * as React from "react";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { cn } from "@/lib/utils";
/** The three phosphor coatings a monochrome CRT could be built with. */
export type PhosphorColor = "amber" | "green" | "white";
export interface PhosphorProps
extends Omit<React.ComponentPropsWithoutRef<"div">, "color"> {
/** Controlled phosphor coating. Pair with `onPhosphorChange`. */
phosphor?: PhosphorColor;
/** Initial phosphor when uncontrolled. @default "amber" */
defaultPhosphor?: PhosphorColor;
/** Fires with the next coating whenever the switcher changes it. */
onPhosphorChange?: (phosphor: PhosphorColor) => void;
/** Render the built-in amber / green / white switcher strip. @default true */
switcher?: boolean;
/** Terminal content. Falls back to a boot readout when omitted. */
children?: React.ReactNode;
/** Show the blinking block caret after the content. @default true */
caret?: boolean;
/** Scanline + vignette strength, 0–1. @default 1 */
intensity?: number;
/** Blink / flicker / roll rate multiplier. @default 1 */
speed?: number;
/** Freeze all motion — steady glow, solid caret, no flicker. @default false */
paused?: boolean;
/** Override the three phosphor text hues (any CSS hex color). */
colors?: Partial<Record<PhosphorColor, string>>;
/** Accessible label for the switcher radiogroup. @default "Phosphor coating" */
switcherLabel?: string;
}
const ORDER: PhosphorColor[] = ["amber", "green", "white"];
/** Deliberately amber-first — the coating every terminal clone forgets exists. */
const DEFAULT_HUES: Record<PhosphorColor, string> = {
amber: "#ffb000",
green: "#38ff7b",
white: "#eef2ff",
};
const LABELS: Record<PhosphorColor, string> = {
amber: "Amber",
green: "Green",
white: "White",
};
/** Default boot readout — generic terminal copy, no brand names. */
const DEFAULT_CONTENT = `PHOSPHOR TERMINAL v1.0
MEMORY ......... 64K OK
DISPLAY ........ ONLINE
READY.
> `;
/** Parse a #rgb/#rrggbb hex into a space-separated "r g b" for modern rgb(). */
function hexToRgbTriplet(hex: string): string {
const h = hex.replace("#", "");
const full =
h.length === 3
? h
.split("")
.map((c) => c + c)
.join("")
: h;
const n = Number.parseInt(full, 16);
if (Number.isNaN(n)) return "255 176 0";
return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`;
}
/**
* Phosphor — a themed panel styled as a monochrome CRT monitor. A dark,
* vignetted screen carries glowing selectable monospace text over fine
* scanlines, a slow refresh bar rolls down the glass, and a hard block caret
* blinks at the prompt. The built-in switcher recoats the tube between the
* three classic phosphors — amber, green, and paper-white — driving one hue
* through every layer via a CSS variable. All content is real, selectable
* text; the effect layers are `aria-hidden` and never intercept the pointer.
* Pure CSS. Reduced motion (or `paused`) holds a steady glow with a solid
* caret and no flicker.
*/
export function Phosphor({
phosphor,
defaultPhosphor = "amber",
onPhosphorChange,
switcher = true,
children,
caret = true,
intensity = 1,
speed = 1,
paused = false,
colors,
switcherLabel = "Phosphor coating",
className,
style,
...rest
}: PhosphorProps) {
const reducedMotion = useReducedMotion();
const animate = !reducedMotion && !paused;
const isControlled = phosphor !== undefined;
const [internal, setInternal] = React.useState<PhosphorColor>(defaultPhosphor);
const active = isControlled ? phosphor : internal;
const hues = { ...DEFAULT_HUES, ...colors };
const hue = hues[active] ?? DEFAULT_HUES[active];
const rgb = hexToRgbTriplet(hue);
const i = Math.min(Math.max(intensity, 0), 1);
const spd = Math.min(Math.max(speed, 0.1), 4);
const radioRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const select = React.useCallback(
(next: PhosphorColor) => {
if (!isControlled) setInternal(next);
onPhosphorChange?.(next);
},
[isControlled, onPhosphorChange]
);
const handleRadioKey = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
let next = index;
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
next = (index + 1) % ORDER.length;
break;
case "ArrowLeft":
case "ArrowUp":
next = (index - 1 + ORDER.length) % ORDER.length;
break;
case "Home":
next = 0;
break;
case "End":
next = ORDER.length - 1;
break;
default:
return;
}
event.preventDefault();
select(ORDER[next]);
radioRefs.current[next]?.focus();
},
[select]
);
return (
<div
data-crucible="phosphor"
data-phosphor={active}
data-animate={animate ? "on" : "off"}
className={cn(
"inline-flex flex-col overflow-hidden rounded-2xl",
"bg-neutral-900 p-2.5 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_20px_50px_-20px_rgba(0,0,0,0.9)]",
"ring-1 ring-white/5",
className
)}
style={
{
"--phos": hue,
"--phos-rgb": rgb,
"--phos-i": i,
"--phos-speed": spd,
...style,
} as React.CSSProperties
}
{...rest}
>
<style>{`
@keyframes crucible-phosphor-blink {
0%, 49.9% { opacity: 1; }
50%, 99.9% { opacity: 0; }
}
@keyframes crucible-phosphor-flicker {
0%, 100% { opacity: 0.5; }
8% { opacity: 0.85; }
9% { opacity: 0.35; }
20% { opacity: 0.6; }
50% { opacity: 1; }
52% { opacity: 0.45; }
70% { opacity: 0.7; }
88% { opacity: 0.9; }
}
@keyframes crucible-phosphor-roll {
0% { transform: translateY(-140%); }
100% { transform: translateY(140%); }
}
[data-crucible="phosphor"] .cru-phos-caret {
animation: crucible-phosphor-blink calc(1.05s / var(--phos-speed, 1)) linear infinite;
}
[data-crucible="phosphor"] .cru-phos-flicker {
animation: crucible-phosphor-flicker calc(5.5s / var(--phos-speed, 1)) linear infinite;
}
[data-crucible="phosphor"] .cru-phos-bar {
animation: crucible-phosphor-roll calc(7s / var(--phos-speed, 1)) linear infinite;
}
[data-crucible="phosphor"][data-animate="off"] .cru-phos-caret,
[data-crucible="phosphor"][data-animate="off"] .cru-phos-flicker,
[data-crucible="phosphor"][data-animate="off"] .cru-phos-bar {
animation: none;
}
`}</style>
{/* The screen — a stacking context so the -z effect layers stay behind text. */}
<div
className="relative isolate min-h-[180px] min-w-[240px] overflow-hidden rounded-xl bg-black"
style={{
backgroundImage: `radial-gradient(120% 100% at 50% 42%, rgb(var(--phos-rgb) / calc(0.12 * var(--phos-i))), transparent 62%)`,
}}
>
{/* Selectable terminal content. */}
<div className="relative z-10 h-full w-full p-5">
<div
className="font-mono text-[13px] leading-relaxed whitespace-pre-wrap [word-break:break-word]"
style={{
color: "var(--phos)",
textShadow:
"0 0 1px currentColor, 0 0 6px rgb(var(--phos-rgb) / 0.55), 0 0 16px rgb(var(--phos-rgb) / 0.28)",
}}
>
{children ?? DEFAULT_CONTENT}
{caret && (
<span
aria-hidden
className="cru-phos-caret ml-0.5 inline-block h-[1.05em] w-[0.6em] translate-y-[0.18em] align-baseline"
style={{
background: "currentColor",
boxShadow: "0 0 8px rgb(var(--phos-rgb) / 0.7)",
}}
/>
)}
</div>
</div>
{/* Fine scanlines. */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-20"
style={{
backgroundImage: `repeating-linear-gradient(to bottom, rgb(0 0 0 / calc(0.34 * var(--phos-i))) 0 1px, transparent 1px 3px)`,
}}
/>
{/* Slow refresh bar rolling down the glass. */}
<div
aria-hidden
className="cru-phos-bar pointer-events-none absolute inset-x-0 top-0 z-20 h-1/3"
style={{
background: `linear-gradient(to bottom, transparent, rgb(255 255 255 / calc(0.05 * var(--phos-i))), transparent)`,
}}
/>
{/* Phosphor bloom flicker. */}
<div
aria-hidden
className="cru-phos-flicker pointer-events-none absolute inset-0 z-20 mix-blend-screen"
style={{
background: `rgb(var(--phos-rgb) / calc(0.05 * var(--phos-i)))`,
}}
/>
{/* Vignette + curved-glass edge darkening. */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-30 rounded-xl"
style={{
background: `radial-gradient(120% 120% at 50% 50%, transparent 52%, rgb(0 0 0 / calc(0.6 * var(--phos-i))) 100%)`,
boxShadow: `inset 0 0 40px rgb(0 0 0 / 0.7), inset 0 0 3px rgb(var(--phos-rgb) / 0.3)`,
}}
/>
</div>
{/* Switcher — recoat the tube. A real radiogroup with roving focus. */}
{switcher && (
<div
role="radiogroup"
aria-label={switcherLabel}
className="mt-2.5 flex items-center justify-center gap-1.5"
>
{ORDER.map((p, index) => {
const on = p === active;
const swatch = hues[p] ?? DEFAULT_HUES[p];
return (
<button
key={p}
ref={(el) => {
radioRefs.current[index] = el;
}}
type="button"
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => select(p)}
onKeyDown={(event) => handleRadioKey(event, index)}
className={cn(
"inline-flex cursor-pointer items-center gap-1.5 rounded-md px-2.5 py-1",
"font-mono text-[11px] tracking-wide uppercase transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900",
on
? "bg-white/10 text-neutral-100"
: "text-neutral-500 hover:text-neutral-300"
)}
>
<span
aria-hidden
className="h-2.5 w-2.5 rounded-full"
style={{
background: swatch,
boxShadow: on ? `0 0 7px ${swatch}` : "none",
}}
/>
{LABELS[p]}
</button>
);
})}
</div>
)}
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/phosphorProps
| Prop | Type | Default | Description |
|---|---|---|---|
| phosphor | PhosphorColor | Controlled phosphor coating. Pair with onPhosphorChange. | |
| defaultPhosphor | PhosphorColor | "amber" | Initial phosphor when uncontrolled. |
| onPhosphorChange | (phosphor: PhosphorColor) => void | Fires with the next coating whenever the switcher changes it. | |
| switcher | boolean | true | Render the built-in amber / green / white switcher strip. |
| children | React.ReactNode | Terminal content. Falls back to a boot readout when omitted. | |
| caret | boolean | true | Show the blinking block caret after the content. |
| intensity | number | 1 | Scanline + vignette strength, 0–1. |
| speed | number | 1 | Blink / flicker / roll rate multiplier. |
| paused | boolean | false | Freeze all motion — steady glow, solid caret, no flicker. |
| colors | Partial<Record<PhosphorColor, string>> | Override the three phosphor text hues (any CSS hex color). | |
| switcherLabel | string | "Phosphor coating" | Accessible label for the switcher radiogroup. |
Also accepts all props of Omit<React.ComponentPropsWithoutRef<"div">, "color"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.