The Layered Approach to 3D Text
CSS can position elements in three-dimensional space with full perspective control, but it cannot give an element actual depth. There is no depth property. To simulate volume, developers typically choose between two strategies: constructive or layered.
The constructive method assembles flat elements like digital Lego bricks, rotating and positioning each side of a shape in 3D space. Powerful, but fiddly. The layered method, which this article explores, takes a different route: stack many copies of the same element, shift each slightly along the z-axis, and vary color to create the illusion of depth. It is not a true 3D object, but the eye reads the result as volume, especially with text, rounded shapes, and UI components.
One important caveat before building: extra text layers can confuse screen readers. Wrap every decorative duplicate in aria-hidden="true" so assistive technologies ignore them.
Building the Layer Stack
Start with a container element holding the visible text in a span and a separate wrapper for the replicated layers:
<div class="text">
<span>Lorem ipsum</span>
<div class="layers" aria-hidden="true"></div>
</div>
Inside the layers wrapper, create multiple copies of the text. In this static example, a preprocessor loop or Emmet can generate them. You will end up with a structure where each layer carries an inline style attribute defining its index:
<div class="layers" aria-hidden="true">
<div class="layer"></div>
<div class="layer"></div>
<div class="layer"></div>
<!-- ...More layers -->
</div>
Indexing simply assigns each layer a custom property, conventionally called --i, starting at 1 for the first layer. Later this value drives positional and color calculations. Two common ways to assign indexes:
- CSS: Use
:nth-childrules in the stylesheet. - Inline: Set the
styleattribute directly on each element.
.layer {
&:nth-child(1): { --i: 1; }
&:nth-child(2): { --i: 2; }
&:nth-child(3): { --i: 3; }
/* ... More layers */
}
or:
<div class="layers" aria-hidden="true">
<div class="layer" style="--i: 1;"></div>
<div class="layer" style="--i: 2;"></div>
<div class="layer" style="--i: 3;"></div>
<!-- ...More layers -->
</div>
The inline approach is preferred here because it keeps the markup independent from the stylesheet and makes examples easier to copy. If your editor supports Emmet, generate 24 layers instantly with .layer*24[style="--i: $;"], where $ increments automatically. (In the future, the experimental sibling-index() CSS function may remove the need for explicit variables entirely.)
Filling the Layers
Each layer must carry the same text. A CSS-only dynamic approach places the text in a custom property and injects it via a pseudo-element:
.layer {
--text: "Lorem ipsum";
&::before {
content: var(--text);
}
}
This keeps the source text in one place but consumes pseudo-elements that might be needed for decorative borders later. For a static example, simply place the text directly inside each layer. Emmet handles this cleanly: .layer*24[style="--i: $;"]{Lorem ipsum}.
Positioning and Depth
All layers must overlap precisely. Use position: relative on the container, and position: absolute with inset: 0 on the layers wrapper and every layer inside it:
.text {
position: relative;
.layers, .layer {
position: absolute;
inset: 0;
}
}
Now bring in perspective. Without it, moving elements along the z-axis has no visible effect. Apply perspective on a parent wrapper (call it .scene) rather than on individual layers. Every child of the scene needs transform-style: preserve-3d;; otherwise, the browser flattens the transformed children into a single plane and ignores z-axis movement:
.scene {
perspective: 400px;
* {
transform-style: preserve-3d;
}
}
Lower perspective values produce a stronger, more exaggerated 3D effect; higher values flatten the scene. Tune it to your design.
Separating the Layers
Two custom properties control layer spacing: --layers-count holds the total number of layers, and --layer-offset defines the gap between consecutive layers:
.text {
--layers-count: 24;
--layer-offset: 1px;
}
Each layer’s transform multiplies its index by the offset to set its z position:
.layer {
transform: translateZ(calc(var(--i) * var(--layer-offset)));
}
The result, at this stage, is underwhelming. Bare text shifted along the z-axis reads as a muddy mess. Depth perception requires color and lighting variation.
Lighting and Shadows
For proportional color calculations, normalize the index by dividing it by the total layer count, producing a value from 0 to 1 that remains balanced if the number of layers changes:
.layer {
--n: calc(var(--i) / var(--layers-count));
}
Use that normalized value in an HSL function to shift brightness gradually from the top layer to the bottom:
.layer {
color: hsl(200 30% calc(var(--n) * 100%));
}
This gradient of brightness is what makes the stacked layers read as a solid, three-dimensional object. The original wrapped span, which sits at the bottom of the stack, should be given a dark color (black works in most cases) so it anchors the depth:
span {
color: black;
text-shadow: 0 0 0.1em #003;
}
Polish
Choice of typeface matters. A bold, chunky font suits the effect; pick whatever fits the brand. Finally, add a subtle rotation on the x-axis so the lettering presents at a more dynamic angle:
.text {
font-family: Montserrat, sans-serif;
font-weight: 900;
transform: rotateX(30deg);
}
What looked flat and messy a moment ago now resolves into a clean, bulging three-dimensional text effect, built entirely from stacked flat elements and smart color shifting. The structure is static for now, but it forms a solid foundation for motion and interactive variations.



