Text layering
Keep your headline as real HTML and let transparent ASCII characters pass over it. Descriptions, links, and playback controls stay above the artwork. This is the layering used on our cover.
Independent text and artwork layers
| Option | Type | Default | Description |
|---|---|---|---|
Headline | HTML h1 | z-index: 0 | Readable, selectable text beneath the glyphs. |
Artwork | canvas | z-index: 1 | Transparent gaps reveal the headline. Keep pointer-events: none. |
Description and CTA | HTML | z-index: 2 | Keep small text and links clear of the moving artwork. |
Motion controls | button | z-index: 3 | Keyboard-operable controls remain clickable above every layer. |
.ascii-hero { position: relative; isolation: isolate; overflow: hidden; }
.hero-title { position: relative; z-index: 0; }
.hero-art {
position: absolute; inset: 0; z-index: 1;
background: transparent; pointer-events: none;
}
.hero-entry { position: relative; z-index: 2; }
.hero-controls { position: absolute; z-index: 3; }Complete working example
Copy these files into a Vite project with asciify-engine installed. Rename the HTML file to index.html and supply your own optimized video and poster. The example includes pause, reduced motion, offscreen suspension, and cleanup. It uses the published package; the optional studio hover module is separate.
HTML structure
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Layered ASCII hero</title>
<link rel="stylesheet" href="./layered-hero.css">
</head>
<body>
<!-- Replace these two URLs with your own optimized media. -->
<section class="ascii-hero" data-video="/hero.mp4" data-poster="/hero-poster.jpg" data-render-limit="720" aria-labelledby="hero-title">
<h1 id="hero-title" class="hero-title">More than<br>characters.</h1>
<canvas class="hero-art" aria-hidden="true"></canvas>
<div class="hero-entry">
<p>Turn your images and motion into living characters.</p>
<a href="#start">Start creating</a>
</div>
<div class="hero-controls">
<button type="button" data-motion disabled>Loading artwork…</button>
<span data-status role="status"></span>
</div>
</section>
<main id="start" tabindex="-1"><h2>Your next section</h2></main>
<script type="module">
import { mountLayeredHero } from './layered-hero.js';
const destroy = mountLayeredHero(document.querySelector('.ascii-hero'));
// In an SPA, call destroy() when this view unmounts.
if (import.meta.hot) import.meta.hot.dispose(destroy);
</script>
</body>
</html>
Responsive styling
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #080808; color: #e0e0e0; }
* { box-sizing: border-box; }
body { margin: 0; }
.ascii-hero {
position: relative; isolation: isolate; overflow: hidden;
min-height: 44rem; height: 100svh; background: #080808;
display: grid; grid-template-columns: 1.6fr 1fr; align-items: end;
gap: 8%; padding: 6rem 5% 5rem;
}
/* These are siblings: no shared content wrapper traps the CTA under the canvas. */
.hero-title { position: relative; z-index: 0; margin: 0; font-size: clamp(4rem, 9vw, 9rem); line-height: .95; letter-spacing: -.055em; color: #e8b900; }
.hero-art { position: absolute; inset: 0; z-index: 1; width: 100%; height: 100%; object-fit: cover; pointer-events: none; background: transparent; opacity: .65; }
.hero-entry { position: relative; z-index: 2; max-width: 24rem; line-height: 1.7; }
.hero-entry p { text-shadow: 0 1px 4px #000, 0 0 10px #000, 0 0 24px #000; }
.hero-entry a, .hero-controls button { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: .8rem 1.2rem; border: 1px solid #e8b900; border-radius: .75rem; font: inherit; text-decoration: none; cursor: pointer; }
.hero-entry a { background: #e8b900; color: #111; }
.hero-controls { position: absolute; z-index: 3; top: 1.5rem; right: 5%; display: flex; gap: .75rem; align-items: center; font-size: .8rem; }
.hero-controls button { background: #101010; color: #e0e0e0; border-color: #555; }
.hero-controls button:disabled { cursor: default; opacity: .7; }
.hero-controls [data-status] { max-width: 15rem; background: #080808; }
:is(a, button):focus-visible { outline: 3px solid #fff; outline-offset: 5px; }
/* Optional conventional hierarchy: all text above the artwork. */
.ascii-hero.text-in-front .hero-title { z-index: 2; }
#start { min-height: 40vh; padding: 3rem 5%; }
@media (max-width: 650px) {
.ascii-hero { grid-template-columns: 1fr; align-content: end; height: auto; min-height: 100svh; padding: 20rem 6% 3rem; gap: 2rem; }
.hero-title { font-size: clamp(3.5rem, 15vw, 6rem); }
.hero-entry { max-width: 26rem; }
.hero-controls { right: 6%; left: 6%; flex-wrap: wrap; }
}
Playback and lifecycle
import { asciify, asciifyVideo } from 'asciify-engine';
/** Client-side example. Supply data-video and data-poster on the hero element.
* @param {HTMLElement} hero
*/
export function mountLayeredHero(hero) {
const canvas = hero.querySelector('canvas');
const button = hero.querySelector('button[data-motion]');
const status = hero.querySelector('[data-status]');
if (!(button instanceof HTMLButtonElement) || !canvas || !status) throw new Error('Layered hero requires its canvas, motion button, and status element.');
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
/** @type {Partial<import('asciify-engine').AsciiOptions>} */
const mediaOptions = { charset: ' .:-=+*#%@', colorMode: 'fullcolor', normalize: false, animationStyle: 'none', hoverStrength: 0 };
let disposed = false, stop, video, visible = false, userPaused = false;
let loading = false, started = false, failed = false, revision = 0;
const wantsMotion = () => !disposed && visible && !document.hidden && !reduced.matches && !userPaused;
const label = () => {
button.disabled = reduced.matches || failed || !video;
button.textContent = reduced.matches ? 'Motion off' : failed ? 'Still preview' : !video ? 'Loading artwork…' : userPaused || video.paused ? 'Play motion' : 'Pause motion';
};
const release = () => { stop?.(); stop = undefined; video?.pause(); video = undefined; };
async function start() {
if (loading || started || failed || !wantsMotion()) return;
loading = true; started = true;
const version = ++revision;
try {
const cleanup = await asciifyVideo(hero.dataset.video, canvas, {
fitTo: hero, objectFit: 'cover', objectPosition: 'center',
fontSize: 7, fps: 24, maxRenderDimension: Number(hero.dataset.renderLimit) || 720, options: mediaOptions,
onReady: readyVideo => {
if (disposed || version !== revision) { readyVideo.pause(); return; }
video = readyVideo;
if (!wantsMotion()) video.pause();
label();
},
});
if (disposed || version !== revision) cleanup();
else { stop = cleanup; if (!wantsMotion()) video?.pause(); }
} catch {
if (!disposed && version === revision) { failed = true; status.textContent = 'Motion unavailable. The artwork remains a still preview.'; }
} finally {
if (version === revision) { loading = false; label(); }
}
}
async function sync() {
if (disposed) return;
if (!wantsMotion()) video?.pause();
else if (video) {
try { await video.play(); }
catch { if (!disposed) { userPaused = true; status.textContent = 'Press Play motion to start.'; } }
} else await start();
if (!disposed) label();
}
// Keep explicit user pause separate from temporary offscreen/tab suspension.
const onClick = () => { userPaused = video ? !video.paused : false; status.textContent = ''; void sync(); };
button.addEventListener('click', onClick);
document.addEventListener('visibilitychange', sync);
reduced.addEventListener('change', sync);
const observer = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; void sync(); }, { threshold: .05 });
// Paint a small still first. Reduced motion does not request the video URL.
void asciify(hero.dataset.poster, canvas, { fontSize: 7, options: mediaOptions }).then(cleanup => {
if (disposed) { if (typeof cleanup === 'function') cleanup(); return; }
if (typeof cleanup === 'function') cleanup(); // This example uses a static poster.
observer.observe(hero); label();
}).catch(() => {
if (!disposed) { status.textContent = 'Still preview unavailable.'; observer.observe(hero); label(); }
});
return () => {
disposed = true; revision++; release(); observer.disconnect();
button.removeEventListener('click', onClick);
document.removeEventListener('visibilitychange', sync);
reduced.removeEventListener('change', sync);
};
}
Hero finish
The /hover surface can darken ASCII only over your headline's text shapes (textMask) and add a stationary fringe and scanline texture toward the edges (edgeEffect), while the center stays sharp. Pass the current object frame with getFrame for the exact glyph path.
import { DEFAULT_OPTIONS, imageToAsciiFrame, renderFrameToCanvas } from 'asciify-engine/core';
import { mountHover } from 'asciify-engine/hover';
const options = {
...DEFAULT_OPTIONS,
charset: ' .:-=+*#%@', fontSize: 8, charAspect: 0.58,
colorMode: 'accent' as const, accentColor: '#888888',
hoverStrength: 0, animationStyle: 'none' as const,
};
const frame = imageToAsciiFrame(image, options, width, height).frame;
renderFrameToCanvas(canvas.getContext('2d')!, frame, options, width, height);
const surface = mountHover(host, canvas, {
getFrame: () => frame,
effect: 'trail', strength: 0.55, radius: 0.45,
fontSize: 8, charAspect: 0.58, charset: options.charset,
colorMode: 'accent', accentColor: '#888888',
textMask: host.querySelector('h1')!, // or an array of elements
filter: 'prism', edgeEffect: 0.95, edgeSoftness: 0.35,
});
surface.canvas.setAttribute('aria-hidden', 'true');
// surface.update({ textMask: undefined }) removes the cutout. On cleanup: surface.destroy();| Option | Type | Default | Description |
|---|---|---|---|
textMask | HTMLElement | HTMLElement[] | unset | Plain horizontal HTML text to darken ASCII over. Rebuilds on layout and font changes; call invalidate() after a move without a resize. Never include buttons. |
filter | 'clean' | 'signal' | 'prism' | 'etch' | 'prism' | Stationary character finish, independent of the hover effect. clean turns it off. |
edgeEffect | 0–1 | 0; 1 with a filter | Peripheral finish intensity. GPU glyph path with getFrame only; the canvas fallback keeps the cutout but not the finish. |
edgeSoftness | 0–1 | 0 | Prism only: blends a short blur and fade into the outer characters. |
Readability comes first
Check the busiest frame, the loop boundary, and the mobile crop. Reduce artwork opacity where necessary. Add the example’s text-in-front class when the headline should sit above the artwork instead. Keep the canvas decorative with aria-hidden, and preserve visible focus indicators on controls.
To add our enhanced hover interactions, follow Studio surface hovers and keep its overlay canvas at the artwork layer.