From Static Stack to Interactive Typography
Earlier in this series, we built layered 3D text, styled it with depth and color, and animated it. Every example so far was hard-coded markup. This final installment replaces that static structure with JavaScript-generated layers and wires the effect up to real user input. The result is a 3D text system that can adapt its layer count per element, react to hover, and respond to mouse movement.
Generating Layers Programmatically
The markup can be reduced to a single element with the layeredText class — no more hand-written layer divs.
<div class="layeredText">Lorem Ipsum</div>
Layer generation happens in a generateLayers function that accepts the container element. A small safeguard at the top checks whether the element already contains a div with the .layers class; if so, the function returns early. This prevents duplicate rendering when frameworks remount components or a script runs multiple times.
function generateLayers(element) {
// magic goes here
}
function generateLayers(element) {
if (element.querySelector('.layers')) return;
// rest of the logic goes here
}
A constant called DEFAULT_LAYERS_COUNT provides the default layer total, but each element can override it with a data-layers attribute.
const DEFAULT_LAYERS_COUNT = 24;
function generateLayers(element) {
if (element.querySelector('.layers')) return;
const layersCount = element.dataset.layers || DEFAULT_LAYERS_COUNT;
element.style.setProperty('--layers-count', layersCount);
}
The core of generateLayers stores the original text, then rebuilds the element's content as a span followed by a div.layers. A loop iterates over the layer count, appending one layer per pass.
function generateLayers(element) {
// previous code
const content = element.textContent;
element.innerHTML = `
<span>${content}</span>
<div class="layers" aria-hidden="true">
${Array.from({ length: layersCount}, (_, i) =>
`<div class="layer" style="--i: ${i + 1};">${content}</div>`
).join('')}
</div>
`;
}
const layeredElements = document.querySelectorAll('.layeredText');
layeredElements.forEach(generateLayers);
A couple of housekeeping notes from the source: the static --layers-count CSS variable is removed since JavaScript now handles it via setProperty, and the font settings moved out of .layeredText to a global wrapper.
Normalizing Total Stack Height
Because raising the layer count increases overall height with the existing per-layer offset, the source introduces a --text-height variable to replace --layer-offset. The normalized layer index is then multiplied by --text-height to keep the full stack size consistent regardless of how many layers are generated.
.layeredText {
--text-height: 36px;
.layer {
--n: calc(var(--i) / var(--layers-count));
transform: translateZ(calc(var(--n) * var(--text-height)));
color: hsl(200 30% calc(var(--n) * 100%));
}
}
Wiring Up Hover
The first interaction concern is making sure the generated layers don't interfere with mouse events. Adding pointer-events: none; to the .layers element keeps clicks and hovers from being blocked by the stack beneath the primary span.
.layers {
pointer-events: none;
}
To make the 3D effect conditional on hover, the source moves all the dimension-related styling — text color, layer shadows, per-layer translateZ — into a :hover block. The base state is a soft, translucent blue with a simple transition applied; the layers default to transparent and fade in on hover. Adding display: inline-block; to the element prevents line-break issues and enables transforms.
.layeredText {
&:hover {
span {
color: black;
text-shadow: 0 0 0.1em #003;
}
.layer {
color: hsl(200 30% calc(var(--n) * 100%));
transform: translateZ(calc(var(--i) * var(--layer-offset) + 0.5em));
opacity: 1;
}
}
}
.layeredText {
display: inline-block;
span, .layer {
color: hsl(200 100% 75%);
transition: all 0.5s;
}
.layer {
opacity: 0;
}
}
If some elements need permanent depth while others only show it on hover — a heading versus a link, say — the rule can target both selectors in the hover block so only the link changes state.
.layeredText {
&:is(h1, :hover) {
/* full 3D styles here */
}
}
Tracking the Mouse
Mouse interaction begins by capturing the cursor's page coordinates. A mousemove listener on the document writes two custom properties, --mx and --my, onto the body. Using e.pageX and e.pageY rather than the client variants keeps coordinates correct when the page is scrolled.
window.addEventListener('mousemove', e => {
document.body.style.setProperty('--mx', e.pageX);
document.body.style.setProperty('--my', e.pageY);
});
Element positions are collected with a setRects function that loops through all layeredElements and stores the top and left values as CSS custom properties. These come from getBoundingClientRect() plus window.scrollX and scrollY. The function runs once after generation and again on resize, since layout reads are costly to perform repeatedly.
function setRects() {
layeredElements.forEach(element => {
const rect = element.getBoundingClientRect();
element.style.setProperty('--top', rect.top + window.scrollY);
element.style.setProperty('--left', rect.left + window.scrollX);
});
}
setRects();
window.addEventListener('resize', setRects);
Driving Effects with Mouse Data
With both positions stored, a quick demo uses a red-to-white radial gradient that follows the cursor. The gradient's center is shifted by subtracting the element's stored position from the live mouse position.
.layer {
background-clip: text;
color: transparent;
background-image: radial-gradient(circle at center, red 24px, white 0);
}
.layer {
background-image:
radial-gradient(
circle at calc((var(--mx) - var(--left)) * 1px)
calc((var(--my) - var(--top)) * 1px),
red 24px,
white 0
);
}
The mouse position can also be normalized — divided by the total page width and height — to yield values between 0 and 1, which makes effects portable across screen sizes.
document.body.style.setProperty('--nx', e.pageX / document.body.clientWidth);
document.body.style.setProperty('--ny', e.pageY / document.body.clientHeight);
Building the Bulge Effect
Applying that same gradient trick across every layer instead of just the top one produces the bulging look. The gradient color is stored in a custom property referenced per layer.
.layer {
--color: hsl(200 30% calc(var(--n) * 100%));
color: transparent;
background-clip: text;
background-image:
radial-gradient(
circle at calc((var(--mx) - var(--left)) * 1px)
calc((var(--my) - var(--top)) * 1px),
var(--color) 24px,
transparent 0
);
}
To strengthen the transition from the resting state, the base span becomes transparent while its shadow increases in opacity, leaving text nearly invisible until hover. Because that ruins readability on touch devices, the source limits this treatment with a media query that only applies when hover support is detected.
span {
color: transparent;
text-shadow: 0 0 0.1em #0004;
}
@media (hover: hover) {
/* when hover is supported */
}
The final polish targets the bulge shape. Using the normalized layer index directly creates an evenly stepped cone-like form. To make the bulge convex, the source feeds the normalized value into CSS trigonometry — multiplying it by 90 degrees and passing that into cos() — to get a non-linear distribution between 0 and 1.
--cos: calc(cos(var(--n) * 90deg));
That cosine value becomes the gradient radius multiplier. An absolute value keeps the bulge's minimum size from vanishing near zero, and spacing the hard stop of the transparent color away from the colored stop controls the softness of the falloff.
background-image:
radial-gradient(
circle at calc((var(--mx) - var(--left)) * 1px)
calc((var(--my) - var(--top)) * 1px),
var(--color) calc(var(--cos) * 36px + 24px),
transparent calc(var(--cos) * 72px)
);
What This Enables
The system is no longer a fixed block — it's a layer generator with per-element configuration, normalized depth and height, hover states for links, and mouse-tracking gradients that alter what the viewer sees. The article closes by pointing toward further avenues — light, reflection, noise, physics — since the same technique of mapping state into CSS custom properties can drive far more than text bulges.



