Zero dependencies
The published package ships with an empty dependencies field. Nothing to audit, nothing to update.
A darkness interaction engine for the web
Blackout UI places a fullscreen black layer over your existing page and reveals the page — exactly as it already renders — around the pointer, like a flashlight. It does not repaint your site, convert it to dark mode, or touch a single line of your CSS.
Click to activate the flashlight.
Live playground
This isn't a mockup — these controls call the actual
blackout.configure() API on the same instance running the hero above. Turn
Blackout on to see changes as you make them.
How it works
It adds one fullscreen, non-interactive layer above the page and reveals the existing rendered content around the pointer. Nothing about your page changes underneath it.
Not another dark mode library
Traditional dark-mode libraries rewrite your page: backgrounds go black, text goes white, images get adjusted, components get restyled. Blackout UI does something fundamentally different.
Features
The published package ships with an empty dependencies field. Nothing to audit, nothing to update.
Plain JavaScript, TypeScript, React, Next.js, Vue, Svelte — it's just DOM and CSS underneath, no framework required.
Blackout UI adds one overlay element and one scoped stylesheet. It never rewrites your existing HTML or CSS.
Works with light mode, dark mode, or a theme that changes at runtime — the reveal just shows whatever is currently rendered.
Pointer Events unify mouse, pen, and touch, with a configurable touchBehavior for what happens on lift-off.
Every method is a documented no-op when window/document don't exist — safe to import on the server.
The overlay is aria-hidden, non-focusable, and pointer-events: none by default — nothing underneath is blocked.
Loading current build size…
Configuration
Passed to Blackout.init(options) or blackout.configure(options).
Every field is optional; invalid values are clamped to a safe default rather than
thrown.
enabledbooleanWhether the effect is active immediately after init().
Default: true
Blackout.init({ enabled: false });
radiusnumberOuter radius of the flashlight, in CSS pixels.
Default: 180
blackout.configure({ radius: 250 });
softnessnumber (0–100)How gradually the flashlight fades to black. 0 is a hard edge, 100 fades from the very center.
Default: 35
blackout.configure({ softness: 60 });
darknessnumber (0–1)Opacity of the black outside the flashlight. 1 is fully opaque black.
Default: 1
blackout.configure({ darkness: 0.9 });
intensitynumber (0–1)How fully the page is revealed inside the flashlight. 1 is a full, undimmed reveal.
Default: 1
blackout.configure({ intensity: 0.8 });
shape"circle" | "ellipse"Shape of the reveal area.
Default: "circle"
blackout.configure({ shape: 'ellipse' });
touchbooleanWhether touch input moves the flashlight.
Default: true
blackout.configure({ touch: false });
touchBehavior"follow" | "hide" | "persist"What happens to the flashlight when an active touch lifts off. "follow" keeps it in place, "hide" returns to full darkness, "persist" is an explicit alias for "follow".
Default: "follow"
blackout.configure({ touchBehavior: 'hide' });
onPointerLeave"freeze" | "center" | "hide"What happens when the pointer leaves the viewport entirely.
Default: "freeze"
blackout.configure({ onPointerLeave: 'center' });
initialPosition"center" | objectWhere the flashlight sits before any pointer input has been received. Accepts "center" or an explicit { x, y } point.
Default: "center"
Blackout.init({ initialPosition: { x: 100, y: 100 } });
cursor.hidebooleanHides the native cursor while the effect is enabled. Fully restored on disable()/destroy().
Default: false
blackout.configure({ cursor: { hide: true } });
accessibility.respectReducedMotionbooleanReserved for future animated transitions. Pointer tracking itself is never animated regardless of this setting.
Default: true
Blackout.init({ accessibility: { respectReducedMotion: true } });
zIndexnumberz-index of the overlay element.
Default: 2147483647
Blackout.init({ zIndex: 999999 });
API reference
Blackout.init(options?)Does: Mounts the overlay and starts pointer tracking. Idempotent — a second call updates configuration instead of creating a second overlay.
Parameters: optional BlackoutOptions.
Returns: the BlackoutInstance (itself), so you can chain .enable().
Use when: once, on page/component mount.
const blackout = Blackout.init({ radius: 200 });
blackout.configure(options)Does: Updates configuration on the existing instance without recreating the overlay.
Parameters: a partial BlackoutOptions.
Returns: the instance.
Use when: live-updating settings, e.g. from UI controls (see the playground above).
blackout.configure({ radius: 300, softness: 60 });
blackout.enable() / blackout.disable()Does: Turns the effect on/off without destroying the instance or its configuration.
Parameters: none.
Returns: the instance.
Use when: responding to a user action, like this page's "Turn On Blackout" button.
blackout.enable();
blackout.disable();
blackout.toggle()Does: Flips the current enabled state.
Parameters: none.
Returns: the instance.
Use when: a single button should switch between on/off.
blackout.toggle();
blackout.destroy()Does: Fully removes the overlay, listeners, and injected styles; cancels any pending frame; restores the cursor. Safe to init() again afterward.
Parameters: none.
Returns: void.
Use when: unmounting a component (see the React/Vue/Svelte examples below).
blackout.destroy();
blackout.isEnabled() / blackout.isInitialized()Does: State getters.
Parameters: none.
Returns: boolean.
Use when: syncing your own UI (e.g. a status chip) with the instance's state.
if (blackout.isEnabled()) { /* ... */ }
blackout.setPosition(x, y)Does: Manually moves the flashlight to a viewport coordinate.
Parameters: x: number, y: number (CSS pixels, viewport-relative).
Returns: the instance.
Use when: scripting the reveal instead of following the pointer.
blackout.setPosition(400, 250);
blackout.setRadius(n) / setSoftness(n) / setDarkness(n)Does: Shorthand for configure({ radius: n }), etc.
Parameters: n: number.
Returns: the instance.
Use when: updating a single value.
blackout.setRadius(220);
blackout.getOptions()Does: Returns the current, fully-resolved configuration.
Parameters: none.
Returns: ResolvedBlackoutOptions.
Use when: reading back the current radius/softness/etc. — this is exactly how the playground's sliders stay in sync.
const { radius } = blackout.getOptions();
blackout.on(event, fn) / blackout.off(event, fn)Does: Subscribes/unsubscribes to "enable", "disable", "configure", "destroy".
Parameters: event name, listener function.
Returns: the instance (for on/off).
Use when: syncing external UI state to instance lifecycle events.
blackout.on('enable', () => console.log('on'));
createBlackout()Does: Creates an independent Blackout instance, separate from the default singleton.
Parameters: none.
Returns: a new BlackoutInstance.
Use when: an app genuinely needs more than one instance.
import { createBlackout } from 'blackout-ui';
const secondary = createBlackout();
Install
npm install blackout-ui
import { Blackout } from "blackout-ui";
const blackout = Blackout.init();
blackout.enable();
Blackout is also available as the module's default export
(import Blackout from "blackout-ui") — both refer to the same singleton
instance.
import { Blackout } from "blackout-ui";
const blackout = Blackout.init({
radius: 220,
softness: 45,
darkness: 0.95,
intensity: 1,
shape: "circle",
touch: true,
touchBehavior: "follow",
onPointerLeave: "freeze",
cursor: { hide: false },
});
// Nothing is visible until you explicitly enable it:
blackout.enable();
<script src="https://unpkg.com/blackout-ui"></script>
<script>
const blackout = BlackoutUI.init();
blackout.enable();
</script>
dist/index.jsdist/index.cjsdist/blackout-ui.global.js, exposing window.BlackoutUIdist/index.d.tsFramework examples
The full examples live in the repository under examples/. Every one
follows the same rule: call init()/enable() only where code
is guaranteed to run in the browser, and call destroy() on unmount.
No build step required. Import directly, or use the CDN global build.
import Blackout from 'blackout-ui';
const blackout = Blackout.init({ radius: 200, softness: 40 });
document.getElementById('toggle')
.addEventListener('click', () => blackout.toggle());
Mount in useEffect; tear down in its cleanup function. Idempotent init()/reversible destroy() makes this safe under Strict Mode's double-invoke.
useEffect(() => {
const blackout = Blackout.init(options);
return () => blackout.destroy();
}, []);
Blackout UI's methods no-op on the server, so importing it is safe anywhere. Actually calling init() needs a 'use client' component mounted via useEffect.
'use client';
useEffect(() => {
const blackout = Blackout.init(options);
return () => blackout.destroy();
}, []);
Use onMounted/onUnmounted. In Nuxt, this already only runs client-side.
onMounted(() => Blackout.init());
onUnmounted(() => Blackout.destroy());
Use onMount/onDestroy. In SvelteKit, onMount only ever runs in the browser.
onMount(() => Blackout.init());
onDestroy(() => Blackout.destroy());
Use cases
Blackout UI is a building block for interaction, not a replacement for your site's styling. A few places it fits:
A dramatic, deliberate reveal for case studies or hero imagery.
Turn a static product screenshot into something the visitor has to explore.
Use the flashlight as a first-class part of the visual identity.
Darkness-and-reveal as an interaction mechanic, not just decoration.
A single moment of "unveiling" content without building a custom effect from scratch.
Accessibility
aria-hidden="true" and contains no text or focusable elements — it never enters the accessibility tree and can't trap focus.pointer-events: none by default, so hover states, links, buttons, form controls, drag-and-drop, and text selection all keep working exactly as they would without the library.accessibility.respectReducedMotion option exists for future animated transitions; pointer tracking itself has no animation to reduce.Behavior & edge cases
| Situation | Behavior |
|---|---|
| Light/dark theme switching | No special handling needed — the flashlight reveals whatever is currently rendered, live. |
| Fixed / sticky elements | Rendered normally underneath; the overlay is position: fixed to the viewport and repaints independently of page scroll. |
| Modals, images, video, canvas | Revealed as-is through the flashlight. Blackout UI never touches their rendering. |
| Responsive layouts / resize | The overlay listens for resize and repaints; it never assumes a fixed viewport size. |
| Touch devices | Pointer Events cover touch input directly; see the touch / touchBehavior options above. |
| SPA navigation | The overlay is independent of any specific page element, so client-side route changes don't require re-initialization. |
| SSR | Importing the package and calling any method is a documented no-op on the server — see the SSR-safety notes above. |
| Same-origin iframes | Visually covered by the overlay, since it sits above the whole viewport. |
| Cross-origin iframes | Limitation: the overlay cannot reach into a cross-origin iframe's own document — browsers don't allow that, by design. It still visually covers the iframe from the parent page's viewport. |
FAQ
No. It overlays the page with a black layer and cuts a reveal hole around the pointer — it never converts your page into another theme or rewrites its CSS.
Yes. It reveals whatever theme is currently rendered underneath, with no theme detection required.
It adds exactly one overlay <div> and one <style> tag. It never modifies, clones, or restyles your existing elements.
No, by default the overlay is pointer-events: none. Clicks, hovers, and drag interactions pass straight through to your page.
Yes — touch input is handled through the Pointer Events API. See the touch and touchBehavior options.
Yes — see the framework examples above. Call init() in useEffect and destroy() in its cleanup.
Yes. Every public method checks for a browser environment first and is a documented no-op on the server.
No. The core package has no framework dependency and works with plain JavaScript.
No runtime dependencies — the published package's dependencies field is empty.
Yes — blackout.enable(), blackout.disable(), and blackout.toggle(), exactly like the button at the top of this page.