# Phosphor — Theme for React

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

- Page: https://crucibleui.com/components/themes/phosphor
- Category: [themes](https://crucibleui.com/components/themes)
- Tier: free (full source)
- Engine: css

## Install

```bash
npx shadcn@latest add @crucible/phosphor
```

Registry JSON: https://crucibleui.com/r/phosphor.json

Import from `@/components/crucible/phosphor`.

## Props (`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: `Omit<React.ComponentPropsWithoutRef<"div">, "color">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Phosphor, type PhosphorColor } from "@/components/crucible/phosphor";

export default function PhosphorDemo() {
  const [phosphor, setPhosphor] = React.useState<PhosphorColor>("amber");

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.04),transparent_70%)]"
      />
      <div className="relative z-10 flex flex-col items-center gap-6 px-6">
        <Phosphor
          phosphor={phosphor}
          onPhosphorChange={setPhosphor}
          className="w-[340px] max-w-full"
        >
          {`> LOGIN\nUSER .......... GUEST\nACCESS ........ GRANTED\n\nSELECT A COATING BELOW.\nTHE TEXT IS REAL — TRY SELECTING IT.\n> `}
        </Phosphor>
        <p className="text-center text-sm text-neutral-500">
          Change the phosphor — click a coating or focus it and press the arrow keys.
        </p>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`phosphor.tsx`)

```tsx
"use client";

import * as React from "react";

import { useReducedMotion } from "@/hooks/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>
  );
}
```
