@dotopototo/phosphor-vue
v0.4.0
Published
CRT effect for live DOM — Vue 3 directive + component. Barrel distortion, scanlines, phosphor glow, chromatic fringing, noise, jitter, flicker, tracking artifacts.
Maintainers
Readme
@dotopototo/phosphor-vue
CRT visual + audio effect for Vue 3 — barrel, scanlines, phosphor glow, chromatic fringing, and optional programmatic audio (hum, whine, pops, ticks).
Install
bun add @dotopototo/phosphor-vueImport the stylesheet once in your app entry:
import '@dotopototo/phosphor-vue/style.css';Usage
Four ways, pick whichever fits.
Plugin + directive (most common):
// main.ts
import { createApp } from 'vue';
import { PhosphorPlugin } from '@dotopototo/phosphor-vue';
import '@dotopototo/phosphor-vue/style.css';
import App from './App.vue';
createApp(App).use(PhosphorPlugin).mount('#app');<template>
<div v-crt="{ theme: 'phosphor' }">
your content here
</div>
</template>Component wrapper (same effect, slot-based):
<script setup lang="ts">
import { CrtSurface } from '@dotopototo/phosphor-vue';
</script>
<template>
<CrtSurface :options="{ theme: 'amber' }">
your content here
</CrtSurface>
</template>Composable (when you want reactive state alongside the effect):
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import { useCrt } from '@dotopototo/phosphor-vue';
const host = useTemplateRef<HTMLElement>('host');
const options = ref({ theme: 'phosphor' as const, transition: true });
const { snapshot, attached, powered, phase } = useCrt(host, options);
</script>
<template>
<div ref="host">your content here</div>
<button v-if="attached && !powered" @click="options.powered = true">Power on</button>
</template>Imperative (script-driven, framework-agnostic):
import { CRT } from '@dotopototo/phosphor-vue';
const controller = CRT.attach(myElement, { theme: 'apple' });
controller.update({ screen: { jitter: 0.2 } });
controller.detach();Power & lifecycle
Toggle the effect on and off by flipping powered. With the directive, mutate the bound options object; with the composable or controller, call update({ powered }).
<template>
<!-- Snap on/off (default) -->
<div v-crt="{ powered }">…</div>
<!-- Animated, library defaults (1200ms on / 800ms off) -->
<div v-crt="{ powered, transition: true }">…</div>
<!-- Custom durations — clamped to [500, 2000] ms or 0 for snap -->
<div v-crt="{ powered, transition: { on: 1500, off: 600 } }">…</div>
</template>The composable surfaces three reactive views over the controller's live state:
const { snapshot, attached, powered, phase } = useCrt(host, options);
// snapshot.value: { attached, powered, phase }
// attached.value: is the effect bound to the element right now?
// powered.value: live visible state — true during on / powering-on / powering-off,
// false ONLY once the off animation has fully settled.
// phase.value: 'off' | 'powering-on' | 'on' | 'powering-off'Use powered (not options.value.powered) for UI gating like a Power-on button — it stays true through the entire fade-out, so the button doesn't appear over the still-visible screen.
For non-Vue code, subscribe directly to the controller:
const c = CRT.attach(el, { transition: true });
let state = c.snapshot;
const unsubscribe = c.onChange((s) => { state = s; /* re-render */ });controller.attached / controller.powered are plain getters and not reactive — onChange (or the useCrt composable, which uses it internally) is the reactive bridge.
Audio
Optional programmatic audio engine — hum, flyback whine, pops, ticks, zaps, plus power-on/off events. Imported from a separate sub-entry so consumers who don't need audio don't ship it:
import { CrtAudio, useCrtAudio } from '@dotopototo/phosphor-vue/audio';Browsers block AudioContext until a user gesture. The composable holds a session iff BOTH master.enabled === true AND powerIntent === true. The audible startup / shutdown ceremony only plays alongside an animated visual transition — snap on/off stays silent.
<script setup lang="ts">
import { ref } from 'vue';
import { CRT } from '@dotopototo/phosphor-vue';
import { CrtAudio, useCrtAudio } from '@dotopototo/phosphor-vue/audio';
const visualOpts = ref({ powered: true, transition: true });
const audioOpts = ref(CrtAudio.defaults);
const { running, needsGesture, start, audition } = useCrtAudio(
audioOpts,
() => visualOpts.value.powered, // power coordination
() => CRT.normalize(visualOpts.value).transition, // ceremony fires only on animated transitions
);
// Enabling audio in owner mode: flip master.enabled (the source of truth)
// AND call start() in the same click handler. The flip keeps the persisted
// option in sync; start() resumes the AudioContext synchronously inside
// the user-gesture window (the autoplay policy requires gesture-driven
// resume). Skipping start() would push the resume into the reconcile
// watcher's microtask, which may fall outside the gesture window.
function onEnable() {
audioOpts.value.master.enabled = true;
void start();
}
</script>
<template>
<button @click="onEnable">Enable audio</button>
<button @click="audition('startup')">Audition startup</button>
</template>12 named presets ship as a frozen registry:
import { CRT_AUDIO_PRESETS } from '@dotopototo/phosphor-vue/audio';
audioOpts.value = structuredClone(CRT_AUDIO_PRESETS.bunkerTerminal);(roomCrt, cleanOffice, bunkerTerminal, oldTv, dyingTube, palWorkstation, arcadeMonitor, distantRoom, whisper, brandNew, failingPower, serverRoom.)
Options
Rather than mirror every field in this README — they'd drift — explore the options two ways:
- TypeScript types. Your editor surfaces every field with its bounds and defaults as you author config. The relevant types:
CrtOptions,ResolvedCrtOptions,CrtAudioOptions,ResolvedCrtAudioOptions,CrtControllerSnapshot,CrtAudioState. - Live playground. Clone the repo, run
bun run dev, tinker visually, hit Copy Settings to paste the JS literal of your overrides into your code.
If you need bounds at runtime (e.g. building your own settings UI), the same registries the library uses internally are exported:
import { CRT_FIELDS, type CrtNumericFieldPath } from '@dotopototo/phosphor-vue';
import { AUDIO_FIELDS } from '@dotopototo/phosphor-vue/audio';
CRT_FIELDS['screen.barrel']; // { type: 'number', default: 0.018, min: 0, max: 0.035 }
AUDIO_FIELDS['hum.level']; // { type: 'number', default: 0.34, min: 0, max: 1 }Every row is deeply frozen. Keys are type-checked — typos are compile errors.
CRT.defaults and CrtAudio.defaults are the frozen active defaults — i.e. with mode exclusivity applied. CRT.normalize(opts) and CrtAudio.normalize(opts) show how an input resolves.
Caveats
The element you hand to CRT is a positioning shell, not a layout container. When CRT attaches, it moves your children into an internal
.crt-contentwrapper and adds an overlay sibling. Author your layout one level deeper:<!-- bad: grid on the v-crt host breaks when the wrapper is inserted --> <div v-crt style="display: grid; grid-template-rows: auto 1fr auto"> <header/><main/><footer/> </div> <!-- good: grid lives in your own inner div --> <div v-crt> <div style="display: grid; grid-template-rows: auto 1fr auto; height: 100%"> <header/><main/><footer/> </div> </div>CSS is separate. The one stylesheet import is required; there's no auto-inject because it doesn't play nicely with SSR.
SSR-safe import. Importing the package server-side won't crash.
CRT.normalize()andCRT.defaultswork anywhere. DOM-touching methods (attach,attachAll,prime) throw a clear "requires a browser environment" error if called server-side — keep them insideonMounted/useEffect. Thev-crtdirective and<CrtSurface>component are already mount-gated.Modern Chromium only. Chrome, Edge, Brave, Arc, recent Electron. Firefox and Safari may render acceptably but no compatibility work is invested.
Glow + ancestor
overflow: hidden. Text glow is atext-shadowhalo around 30–40px wide. Any ancestor withoverflow: hidden(includingtext-overflow: ellipsis) clips the halo at the box edge, leaving visible hard edges. Put clipping on a wrapper with enough padding, or move it higher up the tree.Form controls. Native rendering (
appearance: button/textfield) draws text via the OS and ignorestext-shadow. To get glow + chromatic on form controls, setappearance: noneand theme them yourself — standard practice for terminal/CRT UIs.Position. If the host has
position: static, the library sets it torelativeso the overlay can anchor. Authoredabsolute/fixed/stickyis preserved.
License
MIT. See LICENSE.
