The Deep Card Conundrum: Unleashing Clipped 3D CSS Effects
This article delves into the challenge of creating 3D CSS 'Deep Cards' with clipped content. It explores common pitfalls like `overflow: clip` flattening 3D context and reveals a breakthrough solution using dynamic `perspective` and `perspective-origin` to achieve stunning, fully rotational 3D effects within boundaries.
In the world of web design, 'cards' are ubiquitous—neat rectangles grouping information, forming the backbone of modern UI. Typically, these cards are as flat as the screens they inhabit, perhaps with a subtle drop shadow to hint at elevation, but the illusion ends there.
But what if a card wasn’t just a surface? What if it was a window?
Enter the Deep Card.
Imagine a card that is not merely a 2D plane, but a container with actual volume, holding a miniature 3D world within. When this card is rotated, elements inside shift in perspective, revealing their depth, much like holding a glass box filled with floating objects.
The effect is mesmerizing, transforming a static element into something tactile and alive, inviting interaction. Whether for a digital trading card game, a premium product showcase, or a portfolio piece designed to impress, the Deep Card adds a layer of polish and 'wow' factor that flat design simply cannot match.
However, building this illusion, especially one that feels right and performs smoothly, proved to be more complex than it initially appeared.
The CSS Trap
While numerous JavaScript libraries offer solutions for this, a pure CSS approach was sought. The challenge was to push stylesheets to their absolute limits, with the conviction that a clean, performant, native CSS solution was attainable.
Those familiar with 3D CSS concepts will recognize the standard procedure:
- Set the Stage: Take a container element and give it some
perspective. - Build the World: Position the child elements in 3D space (
translateZ,rotateX, etc.). - Preserve the Illusion: Apply
transform-style: preserve-3dso all those children share the same 3D space.
This seems straightforward. However, for an authentic 'card' effect, the content must remain within its boundaries. If a 3D element appears to float towards the viewer, it should be clipped by the card's edges, reinforcing the illusion of containment. The intuitive step is to apply overflow: clip (or hidden) to the card. Yet, this action immediately compromises the entire effect.

The Spec Says No
The beautiful 3D scene suddenly flattens, its depth vanishing and the magic dissipating.
Why? This occurs because, as per the CSS Transforms Module Level 2 specification, applying any 'grouping property'—such as overflow (with any value other than visible), opacity less than 1, or filter—forces the element to flatten.
A value of
preserve-3dfortransform-styleis ignored if the element has any grouping property values.
Essentially, an element can either function as a 3D container or clip its content; it cannot perform both functions simultaneously.
For a considerable period, this presented a seemingly insurmountable barrier: how to maintain 3D depth while simultaneously containing elements within their boundaries.
Faking It
If the specification prohibits combining both perspective and clipping, an alternative is to simulate the effect. When true 3D depth is not achievable, faking it becomes a viable strategy.
Simulating perspective is a long-standing technique in 2D graphics. Depth can be faked by adjusting the size and position of elements based on their perceived distance from the viewer. In CSS, this translates to using scale() to diminish elements as they appear 'further away' and translate() to shift them relative to the card's angle.
.card {
/* --mouse-x and --mouse-y values ranage from -1 to 1 */
--tilt-x: calc(var(--mouse-y, 0.1) * -120deg);
--tilt-y: calc(var(--mouse-x, 0.1) * 120deg);
transform: rotateX(var(--tilt-x)) rotateY(var(--tilt-y));
}
.card-layer {
/* Fake perspective with scale and translate */
scale: calc(1 - var(--i) * 0.02);
translate: calc(var(--mouse-x) * (var(--i)) * -20%) calc(var(--mouse-y) * (var(--i)) * -20%);
}
This technique can yield impressive results, with brilliant examples, such as Jhey's demo, showcasing beautiful effects without utilizing a single line of perspective or preserve-3d.
While a solid, performant, and cross-browser compatible approach, often indistinguishable from true 3D for subtle effects, it has limitations. The illusion holds within a restricted range of motion. Pushing it too far, for example, by rotating the card to a sharp angle or attempting a 180-degree flip, reveals the mathematical inconsistencies. The perspective flattens, and the movement loses its natural feel.
When the card turns, the inner elements lose their spatial relationship, and the illusion dissipates. While a valuable technique, it did not provide the complete solution for full 3D, unrestricted rotation, and clipping.
Road to a Nowhere
Years were spent attempting to resolve this problem, driven by the belief that a full solution existed. Theoretically, if the container cannot be clipped, then its children must be.
This would entail dynamically applying clip-path to every inner layer of the card. Real-time calculations would be required to determine the card's edges relative to the viewer, then applying a dynamic clipping mask to each child element to precisely cut off at those boundaries. This complex process involves projecting 3D coordinates onto a 2D plane, calculating intersections, and managing the trigonometry of the user’s perspective.

The task almost led to conceding that this effect might be beyond CSS's capabilities, until a message arrived from Cubiq.
The Breakthrough
This topic was a recurring inquiry. As someone known for pushing CSS 3D boundaries, the question of achieving this effect was often directed to the author, despite not having a definitive solution. When Cubiq presented a GIF of a fully rotating card with deep 3D elements and asked for the implementation method, the initial response was the standard explanation: detailing why the specification forbids it, how overflow flattens the context, and suggesting simulation with scale and translate. However, Cubiq's subsequent revelation was surprising.

My Personal Blind Spot
Despite numerous attempts over the years, one property consistently avoided was perspective-origin. A deeper understanding of CSS perspective calculation reveals that perspective-origin doesn't merely shift the viewpoint; it fundamentally skews the entire viewport, often leading to unnatural distortion. (This topic is explored in detail in the talk '3D in CSS, and the True Meaning of Perspective'). Cubiq, however, approached the problem without preconceptions. With fresh eyes, he made a brilliant discovery: perspective-origin, typically associated with creating distortion, could also be leveraged to correct it.
The Solution
Cubiq devised the following ingenious solution:
.card-container {
transform: rotateX(var(--tilt-x)) rotateY(var(--tilt-y));
}
.card {
perspective: calc(
cos(var(--tilt-x)) * cos(var(--tilt-y)) * var(--perspective)
);
perspective-origin: calc(
cos(var(--tilt-x)) * sin(var(--tilt-y)) * var(--perspective) * -1 + 50%
) calc(sin(var(--tilt-x)) * var(--perspective) + 50%);
overflow: clip;
}
While seemingly complex, the underlying logic is elegant. Since overflow: clip is applied, the 3D context is flattened, causing the browser to render the card and its children as a flat surface. This flattening would typically eliminate the children's 3D effect, making them appear as a flat image on a rotating plane. The critical insight, however, is to use perspective and perspective-origin to counteract this rotation. By dynamically calculating perspective-origin based on the card’s tilt, the browser is instructed to render the children's perspective as if the viewer is observing them from that specific angle. This effectively projection-maps the 3D scene onto the card's 2D surface. The mathematical precise alignment with the card’s physical rotation creates the illusion of a deep, 3D space within an element that the browser otherwise treats as 'flat'.
The essence of this solution is not to move the world inside the card, but to manipulate the flat projection to appear 3D by aligning the viewer's perspective with the card's rotation.
The Lesson
This solution is remarkable not only for its effectiveness but also for the humbling lesson it imparts. The property perspective-origin had been previously dismissed as problematic, creating a mental block due to its association with distortion. The intense focus on conventional 3D methods inadvertently obscured the very tool capable of solving the problem. Cubiq, unburdened by this bias, approached it as a mathematical challenge: 'The projection needs to appear X when the rotation is Y.' This led directly to identifying the property that controls projection.
Breaking It Down
Now that the solution is understood, let's break down exactly what's happening here, step by step, and look at some examples of what can be achieved. Let's start with the basics.
The HTML
At its core, the structure is simple: a .card-container holds the .card, which in turn contains the .card-content (the 'front' of the card where all inner layers reside) and the .card-back for the back face.
<div class="outer-container">
<div class="card">
<div class="card-content">
<!-- Inner layers go here -->
</div>
<div class="card-back">
<!-- Back face content -->
</div>
</div>
</div>
Inside the .card-content, .card-layers can be added, each with a custom property --i to control its depth.
<div class="card-layers">
<div class="card-layer" style="--i: 0"></div>
<div class="card-layer" style="--i: 1"></div>
<div class="card-layer" style="--i: 2"></div>
<div class="card-layer" style="--i: 3"></div>
<!-- more layers as needed -->
</div>
Each layer can then be filled with content, images, text, or any desired elements.
The Movement
To create the rotation effect, mouse position must be tracked and converted into tilt angles for the card. This involves mapping the mouse position into two CSS variables: --mouse-x and --mouse-y.
This is accomplished with a few lines of JavaScript:
const cardContainer = document.querySelector('.card-container');
window.addEventListener('mousemove', (e) => {
const rect = cardContainer.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width * 2 - 1;
const y = (e.clientY - rect.top) / rect.height * 2 - 1;
cardContainer.style.setProperty('--mouse-x', x);
cardContainer.style.setProperty('--mouse-y', y);
});
This provides normalized values between -1 and 1 on each axis, ensuring consistency regardless of card size or aspect ratio.
These values are converted to --tilt-x and --tilt-y in CSS by multiplying them by the desired rotation in degrees:
--tilt-x: calc(var(--mouse-y, 0.1) * -120deg);
--tilt-y: calc(var(--mouse-x, 0.1) * 120deg);
A higher degree value results in a more dramatic rotation; for instance, 20–30 degrees offers a subtle effect, while 180 degrees spins the card fully around.
Note that --mouse-x affects --tilt-y because mouse movement along the X-axis should rotate the card around the Y-axis, and vice versa. Additionally, --mouse-y is multiplied by a negative number as the Y-axis on screen is inverted compared to the mathematical Y-axis.
With --tilt-x and --tilt-y defined, they are applied to the .card element to rotate it in 3D space:
.card {
transform: rotateX(var(--tilt-x)) rotateY(var(--tilt-y));
}
This establishes the basic rotation effect, allowing the card to tilt and spin based on mouse position.
The Perspective
It is crucial to establish two distinct perspectives: one on the .card-container to define the overall 3D scene, and another on the .card-content to manage the depth of its inner elements. On the .card-container, a standard perspective is applied:
.card-container {
perspective: var(--perspective);
}
--perspective can be set to any desired value, with 800px being a good starting point. Lower values create a more dramatic perspective, while higher values make it more subtle.
To preserve the 3D space and ensure all inner elements share the same 3D context, transform-style: preserve-3d is set. The universal selector is utilized here to apply this to all child elements:
* {
transform-style: preserve-3d;
}
For the inner perspective, perspective and perspective-origin are configured on the .card-content element, which houses all the inner layers:
.card-content {
perspective: calc(
cos(var(--tilt-x)) * cos(var(--tilt-y)) * var(--perspective)
);
perspective-origin: calc(
cos(var(--tilt-x)) * sin(var(--tilt-y)) * var(--perspective) * -1 + 50%
) calc(sin(var(--tilt-x)) * var(--perspective) + 50%);
overflow: clip;
}
Notably, overflow: clip is added to .card-content to ensure that inner elements are clipped by the card boundaries. This specific combination of perspective, perspective-origin, and overflow: clip is the key to maintaining the 3D depth of inner elements while keeping them contained within the card.
The Depth
With rotation and perspective configured, depth can now be added to the inner layers. Each layer is positioned in 3D space using translateZ, based on its --i value.
.card-layer {
position: absolute;
transform: translateZ(calc(var(--i) * 1rem));
}
This spaces out the layers along the Z-axis, creating the illusion of depth. The multiplier (here 1rem) can be adjusted to control the separation between layers.
Putting It All Together
Using the techniques outlined above, a fully functional Deep Card can be created that responds to mouse movement, maintains 3D depth, and clips its content appropriately.
Here is a complete boilerplate example:
You can customize it to your needs, set the number of layers, their depth, and add content within each layer to create a wide variety of Deep Card effects.
Getting Deeper
To improve the Deep Card effect and further enhance the perception of depth, shadows and darkening effects can be added to the layers.
One method to achieve darker colors is by directly specifying them. Alternatively, the brightness of each layer can be calculated based on its depth, making deeper layers progressively darker to simulate light falloff.
.card-layer {
color: hsl(0 0% calc(100% - var(--i) * 9%));
}
Another technique involves adding a semi-transparent background to each layer. This way, each layer acts like a screen that slightly darkens the layers behind it, enhancing the depth effect.
.card-layer {
background-color: #2224;
}
Here is an example of two cards with different effects: The first card uses darker colors for deeper layers, while the second card uses semi-transparent overlays to create a more pronounced depth effect.
Choose the one that fits your design best, or combine both techniques for an even richer depth experience.
The z-index Effect
It may be observed that all layers are nested within a container (.card-layers) instead of being direct children of .card-content. This is because when layers are moved along the Z-axis, they should not be direct children of an element with overflow: clip; (such as .card-content).
As previously stated, applying overflow: clip; to .card-content causes its transform-style to become flat, resulting in all its direct children being rendered on a single plane. Their stacking order then defaults to z-index rather than their Z-axis position. Encapsulating the layers within a container preserves their 3D positioning, allowing the depth effect to function correctly.
The Twist
Now that this limitation is understood, it can be leveraged to create interesting effects. Here are the exact same two cards as in the previous example, but this time without a .card-layers container. The layers are direct children of .card-content:
Adding Interaction
A common interaction involves rotating the card 180 degrees to reveal additional content on the back. This technique now enables building an entire 3D world within the card.
In this example, there is a front face (.card-content) and a back face (.card-back). When the user clicks the card, a checkbox toggle rotates the card 180 degrees, revealing the back face.
<label class="card-container">
<input type="checkbox">
<div class="card">
<div class="card-content">
<!-- front face content -->
</div>
<div class="card-back">
<!-- back face content -->
</div>
</div>
</label>
.card-container {
cursor: pointer;
&:has(input:checked) .card {
rotate: y 180deg;
}
input[type="checkbox"] {
display: none;
}
}
A button or any other interactive element can be used to toggle the rotation, depending on the use case, with any desired animation technique for a smooth rotation.
Inner Movement
Of course, animations can also be applied to the inner layers to create dynamic effects, ranging from wild and complex to subtle and elegant. The key is that since the layers are in 3D space, any movement along the Z-axis will enhance the depth effect.
Here's a simple example with parallax layers, where each layer animates its background position on the X-axis. To enhance the depth effect, the layers are animated at different speeds based on their depth:
.card-layer {
animation: layer calc(var(--i) * 8s) infinite linear;
}
And the result:
Deep Text Animation
This technique works beautifully with layered text, opening up a world of creative possibilities. From subtle depth effects to wild, animated 3D lettering, there is immense potential. This concept was further explored in a dedicated article, showcasing over 20 examples, all of which integrate seamlessly within a Deep Card. Below is one such example, adapted to a card context:
Going Full 360
Up until now, the focus has primarily been on layering inner content and utilizing the Z-axis for depth. However, it's possible to take this a step further, breaking away from the layering concept to build a fully 3D object that can spin in all directions.
From here, the possibilities are truly endless. Experimentation can involve adding more interactions, more layers, or even creating effects on both sides of the card to build two complete worlds, one on each face. Alternatively, an effect can be designed that dives deep into the card itself. The only real limit is imagination.
Conclusion
The Deep Card conundrum is now resolved. It is possible to achieve both 3D depth and content clipping, even with full 360-degree rotation, without compromising the illusion. Therefore, when encountering seemingly intractable CSS challenges, it is worthwhile to re-examine properties previously dismissed. The solution may reside within overlooked documentation. Now, go forth and build something profound.