Skip to content
Animation

Animation

ScreenPowerOn

Wrapper that switches its content on like a CRT: dot, square, full picture.

npx bezel-add add screen-power-on

About

A CRT power-on effect for React that switches any content on like an old television set. After a dark warm-up a bright phosphor dot lights at the centre and flickers, a square opens out to full height, widens to the full width with a tiny overshoot, a short burst of static passes, and a faint green, amber or white phosphor tint, glow, scanlines and vignette fade into the real content. The picture is your own markup squeezed with a transform, so it works in a card as well as a full page, and only transform and opacity animate, which keeps it smooth. When it ends every effect layer is removed. Reduced motion gets a 200ms fade, and the optional synthesized thump, whine and static wait for a user gesture.

Key props: play (plays on mount, default true; setting it to true again replays), duration (whole run in ms, default 3000), tint ("green", "amber", "white" or any CSS colour), sound (off by default), onComplete, and a ref with replay()

packages/ui/src/animation/ScreenPowerOn.tsx · 552 lines

"use client";

import {
  forwardRef,
  useCallback,
  useEffect,
  useImperativeHandle,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type ReactNode,
} from "react";

/**
 * Whatever it wraps switches on like an old CRT set. The tube warms in the
 * dark for a beat, a bright phosphor dot appears at the centre and holds,
 * then the deflection opens the picture: a square grows to full height,
 * widens to the full width with a small overshoot, a burst of static passes,
 * and the phosphor tint, glow, scanlines and vignette fade into the normal
 * content.
 *
 * The picture is the real content, squeezed with a transform, never a copy.
 * Only transform and opacity animate (Web Animations API), so the whole run
 * stays on the compositor: no per-frame script, no animated filter, clip-path
 * or border-radius. The squeeze is sized from the wrapper's own box, so it
 * works in a card as well as a full page. Once the run ends every effect
 * layer is removed and the content is exactly what it was.
 *
 * Beats, as fractions of `duration` (3000ms by default):
 *   0 to 0.175  dark: the set is on, the tube warms up
 *   0.175 to 0.31  the dot appears, flickers and holds
 *   0.31 to 0.535  a square opens out to full height
 *   0.535 to 0.685  it widens to the full width, overshooting a touch
 *   0.685 to 0.825  it settles; the glass clears by 0.95
 *
 * Reduced motion gets a 200ms fade and nothing else. Without scripting the
 * content is simply shown.
 */

export type ScreenPowerOnTint = "green" | "amber" | "white" | (string & {});

export type ScreenPowerOnProps = {
  children?: ReactNode;
  /**
   * Plays on mount when true (the default). Changing it to true plays again;
   * changing it to false stops a run and shows the content.
   */
  play?: boolean;
  /** Length of the whole run in ms. Default 3000; the beats keep their proportions. */
  duration?: number;
  /** Phosphor colour: "green" (default), "amber", "white", or any CSS colour. */
  tint?: ScreenPowerOnTint;
  /**
   * A synthesized thump, a faint whine and a burst of static. Off by default,
   * and heard only once the visitor has pressed something on the page.
   */
  sound?: boolean;
  /** Called when a run finishes (not when it is stopped or replaced). */
  onComplete?: () => void;
  className?: string;
  style?: CSSProperties;
};

export type ScreenPowerOnHandle = {
  /** Switch the screen off and play the power-on again. */
  replay: () => void;
};

/* ------------------------------------------------------------- phosphors */

type Phosphor = {
  /** Multiplied over the picture: light, so content keeps its tones. */
  tint: string;
  glow: string;
  vignette: string;
  dot: string;
  halo: string;
  /** Channel weights for the static, 0 to 1. Computed from `base` when absent. */
  noise?: [number, number, number];
  base: string;
};

const GREEN: Phosphor = {
  tint: "#8dffb6",
  glow: "radial-gradient(ellipse 65% 60% at 50% 50%,rgba(215,255,228,.9) 0%,rgba(120,240,165,.45) 45%,rgba(30,90,50,0) 80%)",
  vignette:
    "radial-gradient(ellipse 72% 68% at 50% 50%,transparent 52%,rgba(0,10,3,.9) 100%),radial-gradient(ellipse 42% 26% at 30% 15%,rgba(225,255,235,.12),transparent 70%)",
  dot: "#eefff4",
  halo: "0 0 14px 5px rgba(150,255,190,.85),0 0 60px 22px rgba(60,220,120,.35)",
  noise: [0.75, 1, 0.8],
  base: "#3cdc78",
};

/** The same recipe as the green, worked out from one colour with color-mix. */
function phosphorFrom(base: string): Phosphor {
  const mix = (pct: number, other: string) => `color-mix(in srgb,${base} ${pct}%,${other})`;
  const fade = (color: string, pct: number) => `color-mix(in srgb,${color} ${pct}%,transparent)`;
  return {
    tint: mix(65, "#fff"),
    glow: `radial-gradient(ellipse 65% 60% at 50% 50%,${fade(mix(18, "#fff"), 90)} 0%,${fade(mix(68, "#fff"), 45)} 45%,transparent 80%)`,
    vignette: `radial-gradient(ellipse 72% 68% at 50% 50%,transparent 52%,${fade(mix(5, "#000"), 90)} 100%),radial-gradient(ellipse 42% 26% at 30% 15%,${fade(mix(14, "#fff"), 12)},transparent 70%)`,
    dot: mix(7, "#fff"),
    halo: `0 0 14px 5px ${fade(mix(45, "#fff"), 85)},0 0 60px 22px ${fade(base, 35)}`,
    base,
  };
}

const PHOSPHORS: Record<string, Phosphor> = {
  green: GREEN,
  amber: phosphorFrom("#ff9c26"),
  white: phosphorFrom("#d4e0ff"),
};

const phosphor = (tint: string) => PHOSPHORS[tint] ?? phosphorFrom(tint);

/** Channel weights for the static, read from any CSS colour the canvas understands. */
function noiseWeights(p: Phosphor): [number, number, number] {
  if (p.noise) return p.noise;
  try {
    const cv = document.createElement("canvas");
    cv.width = cv.height = 1;
    const x = cv.getContext("2d");
    if (!x) return [1, 1, 1];
    x.fillStyle = "#808080";
    x.fillStyle = p.base;
    x.fillRect(0, 0, 1, 1);
    const [r, g, b] = x.getImageData(0, 0, 1, 1).data;
    const top = Math.max(r, g, b, 1);
    return [0.6 + (0.4 * r) / top, 0.6 + (0.4 * g) / top, 0.6 + (0.4 * b) / top];
  } catch {
    return [1, 1, 1];
  }
}

const noiseCache = new Map<string, string>();

/** A 128px tile of coloured static, drawn once per colour and kept as a data URL. */
function noiseTile(w: [number, number, number]): string {
  const key = w.map((n) => n.toFixed(3)).join(",");
  const hit = noiseCache.get(key);
  if (hit) return hit;
  let url = "none";
  try {
    const cv = document.createElement("canvas");
    cv.width = cv.height = 128;
    const x = cv.getContext("2d");
    if (x) {
      const im = x.createImageData(128, 128);
      for (let i = 0; i < im.data.length; i += 4) {
        const v = (Math.random() * 255) | 0;
        im.data[i] = v * w[0];
        im.data[i + 1] = v * w[1];
        im.data[i + 2] = v * w[2];
        im.data[i + 3] = 255;
      }
      x.putImageData(im, 0, 0);
      url = `url(${cv.toDataURL()})`;
    }
  } catch {
    /* no canvas: the run simply has no static */
  }
  noiseCache.set(key, url);
  return url;
}

/**
 * The colour behind the wrapper, composited up through translucent
 * ancestors. The tube is filled with it while it plays, so the multiply tint
 * darkens real pixels instead of turning transparent areas bright.
 */
function groundBehind(el: HTMLElement): string {
  const stack: Array<[number, number, number, number]> = [];
  for (let n: HTMLElement | null = el; n; n = n.parentElement) {
    const c = getComputedStyle(n).backgroundColor;
    const m = c.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+)(%?))?\s*\)$/);
    if (!m) {
      if (c && c !== "transparent") return c;
      continue;
    }
    const a = m[4] === undefined ? 1 : parseFloat(m[4]) / (m[5] ? 100 : 1);
    if (a <= 0) continue;
    stack.push([+m[1], +m[2], +m[3], a]);
    if (a >= 1) break;
  }
  let rgb = [255, 255, 255];
  if (!stack.length || stack[stack.length - 1][3] < 1) {
    const scheme = getComputedStyle(document.documentElement).colorScheme || "";
    if (scheme.includes("dark") && window.matchMedia?.("(prefers-color-scheme: dark)").matches) rgb = [18, 18, 18];
  }
  for (let i = stack.length - 1; i >= 0; i--) {
    const [r, g, b, a] = stack[i];
    rgb = [r * a + rgb[0] * (1 - a), g * a + rgb[1] * (1 - a), b * a + rgb[2] * (1 - a)];
  }
  return `rgb(${rgb.map(Math.round).join(",")})`;
}

/* ----------------------------------------------------------------- sound */

let audio: AudioContext | null = null;
let acted = false;

/** Whether the visitor has pressed anything yet. Browsers keep audio silent until then. */
function visitorHasActed() {
  const ua = (navigator as Navigator & { userActivation?: { hasBeenActive: boolean } }).userActivation;
  return acted || !!ua?.hasBeenActive;
}

function audioContext(): AudioContext | null {
  if (!audio) {
    const AC =
      window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
    if (!AC) return null;
    try {
      audio = new AC();
    } catch {
      return null;
    }
  }
  if (audio.state === "suspended") audio.resume().catch(() => {});
  return audio;
}

/**
 * A set coming on: the thump of the switch, a faint line whine from the
 * moment the dot lights, and a burst of static as the picture opens. `k`
 * stretches the beats with `duration`. Returns a function that fades it out.
 */
function crtSound(a: AudioContext, k: number): () => void {
  const out = a.createGain();
  out.connect(a.destination);
  try {
    const t = a.currentTime + 0.01;

    const o = a.createOscillator();
    const g = a.createGain();
    o.frequency.setValueAtTime(70, t);
    o.frequency.exponentialRampToValueAtTime(38, t + 0.35);
    g.gain.setValueAtTime(0.35, t);
    g.gain.exponentialRampToValueAtTime(0.0001, t + 0.4);
    o.connect(g);
    g.connect(out);
    o.start(t);
    o.stop(t + 0.42);

    const n = Math.floor(a.sampleRate * 0.5);
    const buf = a.createBuffer(1, n, a.sampleRate);
    const d = buf.getChannelData(0);
    for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, 4) * (Math.random() < 0.08 ? 1 : 0.25);
    const s = a.createBufferSource();
    const hp = a.createBiquadFilter();
    const ng = a.createGain();
    s.buffer = buf;
    hp.type = "highpass";
    hp.frequency.value = 2500;
    ng.gain.value = 0.09;
    s.connect(hp);
    hp.connect(ng);
    ng.connect(out);
    s.start(t + 0.93 * k);

    const w = a.createOscillator();
    const wg = a.createGain();
    w.frequency.value = 11800;
    wg.gain.setValueAtTime(0.0001, t + 0.52 * k);
    wg.gain.exponentialRampToValueAtTime(0.014, t + 0.65 * k);
    wg.gain.exponentialRampToValueAtTime(0.0001, t + 3 * k);
    w.connect(wg);
    wg.connect(out);
    w.start(t + 0.52 * k);
    w.stop(t + 3.05 * k);
  } catch {
    /* an audio failure never stops the picture */
  }
  return () => {
    try {
      out.gain.setTargetAtTime(0, a.currentTime, 0.015);
    } catch {
      /* closed context */
    }
    window.setTimeout(() => out.disconnect(), 150);
  };
}

/* ---------------------------------------------------------------- styles */

const css = `
:where(.bz-spo){position:relative;display:flex;flex-direction:column}
.bz-spo-tube{position:relative;flex:1 1 auto;min-width:0;min-height:0;transform-origin:50% 50%}
.bz-spo[data-spo-active]{overflow:hidden;overflow:clip}
.bz-spo[data-spo-active]>.bz-spo-tube{opacity:0;overflow:hidden;overflow:clip;isolation:isolate;will-change:transform,opacity}
.bz-spo-glass{position:absolute;inset:0;background:var(--bz-spo-glass,#000);pointer-events:none}
.bz-spo-fx{position:absolute;inset:0;z-index:2147483000;pointer-events:none;opacity:0;will-change:opacity}
.bz-spo-noise{inset:-60px;background-size:128px 128px;will-change:transform,opacity}
.bz-spo-scan{background:repeating-linear-gradient(to bottom,transparent 0 2px,rgba(0,0,0,.3) 2px 4px)}
.bz-spo-dot{position:absolute;left:50%;top:50%;z-index:1;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:2px;opacity:0;pointer-events:none;will-change:transform,opacity}
@media (prefers-reduced-motion:reduce){.bz-spo-glass,.bz-spo-fx,.bz-spo-dot{display:none}}
@media (scripting:none){.bz-spo[data-spo-active]{overflow:visible}.bz-spo[data-spo-active]>.bz-spo-tube{opacity:1}.bz-spo-glass,.bz-spo-fx,.bz-spo-dot{display:none}}
`;

const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;

/* ------------------------------------------------------------- component */

export const ScreenPowerOn = forwardRef<ScreenPowerOnHandle, ScreenPowerOnProps>(function ScreenPowerOn(
  { children, play = true, duration = 3000, tint = "green", sound = false, onComplete, className = "", style },
  ref,
) {
  const rootRef = useRef<HTMLDivElement>(null);
  const tubeRef = useRef<HTMLDivElement>(null);
  const glassRef = useRef<HTMLSpanElement>(null);
  const dotRef = useRef<HTMLSpanElement>(null);
  const glowRef = useRef<HTMLSpanElement>(null);
  const tintRef = useRef<HTMLSpanElement>(null);
  const noiseRef = useRef<HTMLSpanElement>(null);
  const scanRef = useRef<HTMLSpanElement>(null);
  const vigRef = useRef<HTMLSpanElement>(null);

  const ph = useMemo(() => phosphor(tint), [tint]);

  // Read when a run starts, so changing them mid-run never restarts it.
  const latest = useRef({ duration, sound, ph, onComplete });
  latest.current = { duration, sound, ph, onComplete };

  // 0 is at rest; every run gets a new number, so a replay restarts cleanly.
  const runs = useRef(play ? 1 : 0);
  const [run, setRun] = useState(runs.current);
  const replay = useCallback(() => setRun(++runs.current), []);
  useImperativeHandle(ref, () => ({ replay }), [replay]);

  const lastPlay = useRef(play);
  useEffect(() => {
    if (lastPlay.current === play) return;
    lastPlay.current = play;
    if (play) replay();
    else setRun(0);
  }, [play, replay]);

  // Unlock audio inside the visitor's own press, so a later run can be heard.
  useEffect(() => {
    if (!sound) return;
    const unlock = (e: Event) => {
      if (e.type !== "pointerdown" || (e as PointerEvent).pointerType === "mouse") acted = true;
      if (visitorHasActed()) audioContext();
    };
    const types = ["pointerdown", "pointerup", "touchend", "keydown", "click"];
    types.forEach((t) => window.addEventListener(t, unlock, true));
    return () => types.forEach((t) => window.removeEventListener(t, unlock, true));
  }, [sound]);

  useIsoLayoutEffect(() => {
    if (!run) return;
    const root = rootRef.current;
    const tube = tubeRef.current;
    if (!root || !tube) return;

    const { duration: ms, sound: audible, ph: p } = latest.current;
    const anims: Animation[] = [];
    let silence: (() => void) | null = null;
    let ended = false;
    const end = () => {
      if (ended) return;
      ended = true;
      setRun(0);
      latest.current.onComplete?.();
    };

    const W = tube.offsetWidth;
    const H = tube.offsetHeight;
    if (typeof tube.animate !== "function" || !W || !H) {
      end();
      return;
    }

    if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
      const fade = tube.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200, easing: "linear", fill: "both" });
      fade.onfinish = end;
      anims.push(fade);
    } else {
      const glass = glassRef.current;
      const dot = dotRef.current;
      const glow = glowRef.current;
      const tintLayer = tintRef.current;
      const noise = noiseRef.current;
      const scan = scanRef.current;
      const vig = vigRef.current;
      if (!glass || !dot || !glow || !tintLayer || !noise || !scan || !vig) {
        end();
        return;
      }

      // One-time writes before the first frame; nothing is written per frame.
      tube.style.backgroundColor = groundBehind(root);
      dot.style.left = `${tube.offsetLeft + W / 2}px`;
      dot.style.top = `${tube.offsetTop + H / 2}px`;
      noise.style.backgroundImage = noiseTile(noiseWeights(p));

      const D = Math.max(600, Number.isFinite(ms) ? ms : 3000);
      const o: KeyframeAnimationOptions = { duration: D, fill: "both" };
      const S = (x: number, y: number) => `scale(${x.toFixed(5)},${y.toFixed(5)})`;
      // The picture is squeezed into a 12px square that matches the dot, then
      // opened into the largest square the box holds, then to the full box.
      const sx0 = 12 / W;
      const sy0 = 12 / H;
      const sxq = H < W ? H / W : 1;
      const syq = H < W ? 1 : W / H;

      const main = tube.animate(
        [
          { transform: S(sx0, sy0), opacity: 0, offset: 0 },
          { transform: S(sx0, sy0), opacity: 0, offset: 0.305 },
          { transform: S(sx0, sy0), opacity: 1, offset: 0.31, easing: "cubic-bezier(.55,0,.25,1)" },
          { transform: S(sxq, syq), opacity: 1, offset: 0.535, easing: "cubic-bezier(.16,1,.3,1)" },
          { transform: S(1.012, 1.02), opacity: 1, offset: 0.685, easing: "cubic-bezier(.33,0,.25,1)" },
          { transform: S(1, 1), opacity: 1, offset: 0.825 },
          { transform: S(1, 1), opacity: 1, offset: 1 },
        ],
        o,
      );
      anims.push(
        main,
        glass.animate([{ opacity: 1, offset: 0 }, { opacity: 1, offset: 0.825 }, { opacity: 0, offset: 0.96 }, { opacity: 0, offset: 1 }], o),
        glow.animate(
          [
            { opacity: 0.85, offset: 0 },
            { opacity: 0.85, offset: 0.31 },
            { opacity: 0.5, offset: 0.535 },
            { opacity: 0.2, offset: 0.685 },
            { opacity: 0.26, offset: 0.7 },
            { opacity: 0.12, offset: 0.73 },
            { opacity: 0, offset: 0.88 },
            { opacity: 0, offset: 1 },
          ],
          o,
        ),
        tintLayer.animate(
          [
            { opacity: 0.6, offset: 0 },
            { opacity: 0.6, offset: 0.31 },
            { opacity: 0.5, offset: 0.535 },
            { opacity: 0.25, offset: 0.72 },
            { opacity: 0, offset: 0.93 },
            { opacity: 0, offset: 1 },
          ],
          o,
        ),
        noise.animate(
          [
            { opacity: 0, offset: 0 },
            { opacity: 0, offset: 0.42 },
            { opacity: 0.09, offset: 0.5 },
            { opacity: 0.06, offset: 0.66 },
            { opacity: 0, offset: 0.8 },
            { opacity: 0, offset: 1 },
          ],
          o,
        ),
        // The static jitters in whole steps, so it reads as noise rather than a drift.
        noise.animate(
          [
            { transform: "translate(0,0)" },
            { transform: "translate(-37px,21px)" },
            { transform: "translate(18px,-44px)" },
            { transform: "translate(-52px,-9px)" },
            { transform: "translate(29px,33px)" },
            { transform: "translate(-11px,-27px)" },
          ],
          { duration: 180, iterations: Math.ceil(D / 180), easing: "steps(5,jump-none)" },
        ),
        scan.animate(
          [
            { opacity: 0.9, offset: 0 },
            { opacity: 0.9, offset: 0.31 },
            { opacity: 0.8, offset: 0.66 },
            { opacity: 0, offset: 0.93 },
            { opacity: 0, offset: 1 },
          ],
          o,
        ),
        vig.animate(
          [
            { opacity: 1, offset: 0 },
            { opacity: 1, offset: 0.31 },
            { opacity: 0.9, offset: 0.7 },
            { opacity: 0, offset: 0.96 },
            { opacity: 0, offset: 1 },
          ],
          o,
        ),
        dot.animate(
          [
            { opacity: 0, transform: "scale(.4)", offset: 0 },
            { opacity: 0, transform: "scale(.4)", offset: 0.175 },
            { opacity: 1, transform: "scale(1)", offset: 0.19 },
            { opacity: 0.55, offset: 0.21 },
            { opacity: 1, offset: 0.235 },
            { opacity: 0.75, offset: 0.26 },
            { opacity: 1, offset: 0.29 },
            { opacity: 1, transform: "scale(1)", offset: 0.31 },
            { opacity: 0, transform: "scale(2.2)", offset: 0.38 },
            { opacity: 0, transform: "scale(2.2)", offset: 1 },
          ],
          o,
        ),
      );
      main.onfinish = end;

      if (audible && visitorHasActed()) {
        const a = audioContext();
        if (a) silence = crtSound(a, D / 3000);
      }
    }

    return () => {
      ended = true;
      anims.forEach((a) => {
        a.onfinish = null;
        a.cancel();
      });
      silence?.();
      tube.style.backgroundColor = "";
    };
  }, [run]);

  const active = run > 0;

  return (
    <div
      ref={rootRef}
      className={`bz-spo ${className}`.trim()}
      style={style}
      data-spo-active={active ? "" : undefined}
    >
      <style>{css}</style>
      {active ? <span ref={glassRef} aria-hidden className="bz-spo-glass" /> : null}
      <div ref={tubeRef} className="bz-spo-tube">
        {children}
        {active ? (
          <>
            <span ref={glowRef} aria-hidden className="bz-spo-fx" style={{ background: ph.glow }} />
            <span ref={tintRef} aria-hidden className="bz-spo-fx" style={{ background: ph.tint, mixBlendMode: "multiply" }} />
            <span ref={noiseRef} aria-hidden className="bz-spo-fx bz-spo-noise" />
            <span ref={scanRef} aria-hidden className="bz-spo-fx bz-spo-scan" />
            <span ref={vigRef} aria-hidden className="bz-spo-fx" style={{ background: ph.vignette }} />
          </>
        ) : null}
      </div>
      {active ? <span ref={dotRef} aria-hidden className="bz-spo-dot" style={{ background: ph.dot, boxShadow: ph.halo }} /> : null}
    </div>
  );
});