Frontend Masters Boost RSS Feed https://frontendmasters.com/blog Helping Your Journey to Senior Developer Thu, 04 Dec 2025 19:30:22 +0000 en-US hourly 1 https://wordpress.org/?v=6.9 225069128 The Deep Card Conundrum https://frontendmasters.com/blog/the-deep-card-conundrum/ https://frontendmasters.com/blog/the-deep-card-conundrum/#comments Thu, 04 Dec 2025 19:30:21 +0000 https://frontendmasters.com/blog/?p=7957 In the world of web design, we often talk about “cards”. Those neat little rectangles that group information together are the bread and butter of modern UI. But usually, these cards are as flat as the screens they live on. Maybe they have a subtle drop shadow to hint at elevation, but that’s where the illusion ends.

But what if a card wasn’t just a surface? What if it was a window?

Enter the Deep Card.

Imagine a card that isn’t just a 2D plane, but a container with actual volume. A card that holds a miniature 3D world inside it. When you rotate this card, you don’t just see it skew, you see the elements inside it shift in perspective, revealing their depth. It’s like holding a glass box filled with floating objects.

The effect is mesmerizing. It transforms a static element into something tactile and alive. It invites interaction. Whether it’s for a digital trading card game, a premium product showcase, or just a portfolio piece that screams “look at me,” the Deep Card adds a layer of polish and “wow” factor that flat design simply can’t match.

But as I quickly discovered, building this illusion, especially one that feels right and performs smoothly, is a bit more of a puzzle than it first appears.

The CSS Trap

There are plenty of JavaScript libraries out there that can handle this, but I’m a bit of a CSS purist (read: stubborn). I’ve spent years pushing stylesheets to their absolute limits, and I was convinced that a clean, performant, pure CSS solution was hiding in plain sight.

On paper, the logic seems flawless. If you’ve dabbled in 3D CSS before, you know the drill:

  1. Set the Stage: Take a container element and give it some perspective.
  2. Build the World: Position the child elements in 3D space (translateZrotateX, etc.).
  3. Preserve the Illusion: Apply transform-style: preserve-3d so all those children share the same 3D space.

Simple, right?

But here’s the catch. For a true “card” effect, you need the content to stay inside the card boundaries. If a 3D star floats “up” towards the viewer, you don’t want it to break the frame, you want it to be clipped by the card’s edges, reinforcing the idea that it’s inside a container.

So, naturally, you add overflow: clip (or hidden) to the card. And that is the exact moment everything falls apart.

Comparison of overflow properties in CSS: left shows 'overflow: visible;' with layered rectangles, right shows 'overflow: clip;' with clipped edges.

The Spec Says No

Suddenly, your beautiful 3D scene flattens out. The depth vanishes. The magic is gone.

Why? Because according to the CSS Transforms Module Level 2 specification, applying any “grouping property” like overflow (with any value other than visible), opacity less than 1, or filter, forces the element to flatten.

The sad reality: A value of preserve-3d for transform-style is ignored if the element has any grouping property values.

In other words: you can have a 3D container, or you can clip its content. You cannot do both on the same element.

For a long time, this felt like a dead end. How do you keep the 3D depth while keeping the elements contained?!

Faking It

If the spec says we can’t have both perspective and clipping, maybe we can cheat. If we can’t use real 3D depth, perhaps we can fake it.

Faking perspective is a time-honored tradition in 2D graphics. You can simulate depth by manipulating the size and position of elements based on their “distance” from the viewer. In CSS terms, this means using scale() to make things smaller as they get “further away” and translate() to move 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 work wonders. There are some brilliant examples out there, like this one by Jhey, that pull off the effect beautifully without using a single line of perspective or preserve-3d.

It’s a solid approach. It’s performant, it works across browsers, and for subtle effects, it’s often indistinguishable from the real thing.

But it has a ceiling.

The illusion holds up well within a limited range of motion. But the moment you push it too far, say, by rotating the card to a sharp angle or trying to flip it 180 degrees, the math starts to show its cracks. The perspective flattens out, and the movement stops feeling natural.

As you can see, when the card turns, the inner elements lose their spatial relationship. The magic evaporates. So while this is a great tool for the toolbox, it wasn’t the complete solution I was looking for. I wanted the real deal. Full 3D, full rotation, full clipping.

Road to a Nowhere

I spent years (on and off, I’m not that obsessed) trying to crack this. I was convinced there had to be a way to have my cake and eat it too.

Theoretically, there is a way. If you can’t clip the container, you have to clip the children.

Imagine using clip-path on every single layer inside the card. You would need to calculate, in real-time, exactly where the edges of the card are relative to the viewer, and then apply a dynamic clipping mask to each child element so that it cuts off exactly at those boundaries.

This involves a lot of math, even for me. We’re talking about projecting 3D coordinates onto a 2D plane, calculating intersections, and handling the trigonometry of the user’s perspective.

A textured blackboard covered with various mathematical equations, diagrams, and symbols, creating a complex academic backdrop.

I was almost ready to give up and accept that maybe, just maybe, this was one of those things CSS just wasn’t meant to do. And then, I got a message from Cubiq.

The Breakthrough

This wasn’t the first time someone had asked me about this topic. As someone who’s known for pushing CSS 3D to its limits, I get this question a lot. People assume I have the answer. But, well… I didn’t.

So when Cubiq messaged me, showing me a GIF of a fully rotating card with deep 3D elements and asking how to achieve it, I went into auto-pilot. I gave him the standard explanation on why the spec forbids it, why overflow flattens the context, and how he could try to fake it with scale and translate.

I thought that was the end of it, but then, he surprised me.

A screenshot of a messaging app conversation featuring a user's message expressing excitement about a discovery.

My Personal Blind Spot

I’ve tried many tricks over the years, but one property I religiously avoided was perspective-origin.

If you really dig into how CSS calculates perspective, you realize that perspective-origin doesn’t just shift your point of view. It fundamentally skews the entire viewport. It creates this weird, unnatural distortion that usually looks terrible.

I cover this at length in my talk 3D in CSS, and the True Meaning of Perspective, if you’re into that sort of thing.

Cubiq, however, didn’t have my baggage. He looked at the problem with fresh eyes and realized something brilliant: just as perspective-origin can be used to create distortion, it can also be used to correct it.

Alternate blog post title idea: Finally, we found one good use for perspective-origin! 🤣

The Solution

Here is the magic formula that Cubiq came up with:

.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;
}

It looks a bit scary at first glance, but the logic is actually quite elegant.

Since we are using overflow: clip, the 3D context is flattened. This means the browser treats the card as a flat surface and renders its children onto that surface. Normally, this flattening would kill the 3D effect of the children. They would look like a flat painting on a rotating wall.

But here is the trick: We use perspective and perspective-origin to counter-act the rotation.

By dynamically calculating the perspective-origin based on the card’s tilt, we are essentially telling the browser: “Hey, I know you flattened this element, but I want you to render the perspective of its children as if the viewer is looking at them from this specific angle.”

We are effectively projection-mapping the 3D scene onto the 2D surface of the card. The math ensures that the projection aligns perfectly with the card’s physical rotation, creating the illusion of a deep, 3D space inside a container that the browser considers “flat.”

It’s not about moving the world inside of the card, it’s about tricking the flat projection to look 3D by aligning the viewer’s perspective with the card’s rotation.

The Lesson

I love this solution not just because it works (and it works beautifully), but because it taught me a humbling lesson.

I had written off perspective-origin as a “bad” property. I had a mental block against it because I only saw it as a source of distortion. I was so focused on the “right” way to do 3D, that I blinded myself to the tools that could actually solve the problem.

Cubiq didn’t have that bias. He saw a math problem: “I need the projection to look like X when the rotation is Y.” And he found the property that controls projection.

Breaking It Down

Now that we know it’s possible, let’s break down exactly what’s happening here, step by step, and look at some examples of what you can do with it. Let’s start with the basics.

The HTML

At its core, the structure is simple. We have a .card-container that holds the .card, which in turn contains the .card-content, that is the ‘front’ of the card and where all the inner layers live. 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, we can now add .card-layers with multiple layers in it. Here I’m setting a --i custom property on each layer to later 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>

Now we can fill each layer with content, images, text, or whatever we want.

The Movement

To create the rotation effect, we need to track the mouse position and convert it into tilt angles for the card. So the first thing we need to do is to map the mouse position into two CSS variables, --mouse-x and --mouse-y.

This is done with few simple 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 gives us normalized values between -1 and 1 on each axis, so we can use them regardless of the card size or aspect ratio.

We convert these values to --tilt-x and --tilt-y in CSS, by multiplying them by the number of degrees we want the card to rotate:

--tilt-x: calc(var(--mouse-y, 0.1) * -120deg);
--tilt-y: calc(var(--mouse-x, 0.1) * 120deg);

The higher the degree value, the more dramatic the rotation. 20–30 degrees will give us a subtle effect, while 180 degrees will spin the card all the way around.

Notice that --mouse-x affects --tilt-y, because movement of the mouse along the X axis should actually rotate the card around the Y axis, and vice versa. Also, we multiply --mouse-y by a negative number, because the Y axis on the screen is inverted compared to the mathematical Y axis.

Now that we have --tilt-x and --tilt-y, we can start using them. And first, we apply them to the card container to rotate it in 3D space:

.card {
  transform: rotateX(var(--tilt-x)) rotateY(var(--tilt-y));
}

This gives us the basic rotation effect. The card will now tilt and spin based on the mouse position.

The Perspective

We need to remember that we need to set two different perspectives: one for the card’s container (to create the 3D effect), and one for the card’s content (to maintain the depth of the inner elements).

on the .card-container we set a standard perspective:

.card-container {
  perspective: var(--perspective);
}

You can set --perspective to any value you like, but a good starting point is around 800px. Lower values will create a more dramatic perspective, while higher values will make it more subtle.

To preserve the 3D space and making sure all the inner elements share the same 3D context, we set transform-style: preserve-3d. I’m using the universal selector here to apply it to all children elements:

* {
  transform-style: preserve-3d;
}

To deal with the inner perspective, we set up the perspective and perspective-origin on the .card-content element, which holds 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;
}

Note that we added overflow: clip to the .card-content to ensure that the inner elements are clipped by the card boundaries. This combination of perspective, perspective-origin, and overflow: clip is what allows us to maintain the 3D depth of the inner elements while keeping them contained within the card.

The Depth

Now that we have the rotation and perspective set up, we can start adding depth to the inner layers. Each layer will be positioned in 3D space using translateZ, based on its --i value.

.card-layer {
  position: absolute;
  transform: translateZ(calc(var(--i) * 1rem));
}

This will space out the layers along the Z axis, creating the illusion of depth. You can adjust the multiplier (here 1rem) to control how far apart the layers are.

Putting It All Together

Using the techniques outlined above, we can create a fully functional Deep Card 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, we can add shadows and darkening effects to the layers.

One way to achieve darker colors is just using darker colors. We can calculate the brightness of each layer based on its depth, making deeper layers darker to simulate light falloff.

.card-layer {
  color: hsl(0 0% calc(100% - var(--i) * 9%));
}

Another technique is to add semi-transparent background to each layer. This way each layer is like screen that slightly darkens the layers behind it, enhancing the depth effect.

.card-layer {
  background-color: #2224;
}

Here is an example of a 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

You might notice that I’m placing all the layers inside a container (.card-layers) rather than making them direct children of .card-content. The reason is that since we’re moving the layers along the Z axis, we don’t want them to be direct children of an element with overflow: clip; (like .card-content).

As mentioned earlier, once you set overflow: clip; on .card-content, its transform-style becomes flat, which means all of its direct children are rendered on a single plane. Their stacking order is determined by z-index, not by their position along the Z axis. By wrapping the layers in a container, we preserve their 3D positioning and allow the depth effect to work as intended.

The Twist

Now that we understand this limitation, let’s turn it to our advantage and see what kinds of effects we can create with it.

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

We often use cards that need to display extra information. One of my favorite things to do in these cases is to rotate the card 180 degrees and reveal the additional content on the back side. Now, we can do exactly that, and build an entire 3D world inside the card.

In this example, we have a front face (.card-content) and a back face (.card-back). When the user clicks the card, we toggle a checkbox that 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;
  }
}

You can also use a button or any other interactive element to toggle the rotation, depending on your use case, and use any animation technique you like to make the rotation smooth.

Inner Movement

Of course, we can also use any animation on the inner layers to create dynamic effects. It can be wild and complex, or 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 a simple example with parallax layers. each layer animates it’s background position on the X axis, and to enhance the depth effect, I’m animating the layers 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 the concept of layered text, opening up a world of creative possibilities. There’s so much you can do with it, from subtle depth effects to wild, animated 3D lettering.

I actually wrote an entire article about this, featuring 20+ examples, and every single one of them looks fantastic inside a Deep Card. Here’s one of the examples from that article, now living inside a card:

Going Full 360

up until now, we’ve mostly focused on layering our inner content and using the Z axis to create depth. But we can definitely take it a step further, break out of the layering concept, and build a fully 3D object that you can spin around in all directions.

From here, the possibilities are truly endless. You can keep experimenting—add more interactions, more layers, or even create effects on both sides of the card to build two complete worlds, one on each face. Or, go all in and design an effect that dives deep into the card itself. The only real limit is your imagination.

Conclusion

The Deep Card is now a solved problem. We can have our cake (3D depth), eat it (clipping), and even spin it around 360 degrees without breaking the illusion.

So, the next time you hit a wall with CSS, and you’re sure you’ve tried everything, maybe take a second look at those properties you swore you’d never use. You might just find your answer hiding in the documentation you skipped.

Now, go build something deep.

]]>
https://frontendmasters.com/blog/the-deep-card-conundrum/feed/ 3 7957
How to Create 3D Images in CSS with the Layered Pattern https://frontendmasters.com/blog/how-to-create-3d-images-in-css-with-the-layered-pattern/ https://frontendmasters.com/blog/how-to-create-3d-images-in-css-with-the-layered-pattern/#comments Thu, 20 Nov 2025 18:25:52 +0000 https://frontendmasters.com/blog/?p=7802 3D CSS has been around for a while. The earliest implementation of 3D CSS you can find is in one of W3C’s earliest specifications on 3D transforms in 2009. That’s exactly 15 years after CSS was introduced to the web in 1994, so it’s a really long time!

A common pattern you would see in 3D transformations is the layered pattern, which gives you the illusion of 3D CSS, and this is mostly used with text, like this demo below from Noah Blon:

Or in Amit Sheen’s demos like this one:

The layered pattern, as its name suggests, stacks multiple items into layers, adjusting the Z position and colors of each item with respect to their index value in order to create an illusion of 3D.

Yes, most 3D CSS are just illusions. However, did you know that we can apply the same pattern, but for images? In this article, we will look into how to create a layered pattern for images to create a 3D image in CSS.

In order for you to truly understand how 3D CSS works, here’s a quick list of things you need to do before proceeding:

  1. How the CSS perspective works
  2. A good understanding of the x, y, and z coordinates
  3. Sometimes, you have to think in cubes (bonus)

This layered pattern can be an accessibility problem because duplicated content is read as many times as its repeated. That’s true for text, however, for images this can be circumvented by just leaving all the but first alt attribute empty or setting all the duplicated divs with aria-hidden="true" (this one also works for text). This would hide the duplicated content from the user.

The HTML

Let’s start with the basic markup structure. We’re linking up an identical <img> over and over in multiple layers:

<div class="scene"> 
  <div class="image-container">
    <div class="original">
      <img src="https://images.unsplash.com/photo-1579546929518-9e396f3cc809" alt="Gradient colored image with all colors present starting from the center point">
    </div>
    
    <div class="layers" aria-hidden="true">
      <div class="layer" style="--i: 1;"><img src="https://images.unsplash.com/photo-1579546929518-9e396f3cc809" alt=""></div>
      <div class="layer" style="--i: 2;"><img src="https://images.unsplash.com/photo-1579546929518-9e396f3cc809" alt=""></div>
      <div class="layer" style="--i: 3;"><img src="https://images.unsplash.com/photo-1579546929518-9e396f3cc809" alt=""></div>
      <div class="layer" style="--i: 4;"><img src="https://images.unsplash.com/photo-1579546929518-9e396f3cc809" alt=""></div>
      <div class="layer" style="--i: 5;"><img src="https://images.unsplash.com/photo-1579546929518-9e396f3cc809" alt=""></div>
      ...
      <div class="layer" style="--i: 35;"><img src="https://images.unsplash.com/photo-1579546929518-9e396f3cc809" alt=""></div>
    </div>
  </div>
</div>

The first <div> has a “scene” class wrapped around all the layers. Each layer <div> has an index custom property set --i in the style attribute. This index value is very important, as we will use it later to calculate positioning values. Notice how the <div> with class “original” doesn’t have the aria-hidden attribute? That’s because we want the screen reader to read that first image and not the rest.

We’re using the style indexing approach and not sibling-index() / sibling-count() because they are not yet supported globally across all major browsers. In the future with better support, we could remove the inline styles and use sibling-index() wherever we’re using --i in calculations and sibling-count() when you need to total (35 in this blog post).

It’s important we start with a container for our scene as well because we will apply the CSS perspective property, which controls the depth of our 3D element.

The CSS

Setting the scene, we use a 1000px value for the perspective. A large perspective value is typically good, so the 3D element won’t be too close to the user, but feel free to still use any perspective value of your choice.

We then set all the elements, including the image container <div>s to have a transform-style of preserve-3d. This allows the stacked items to be visible in the 3D space.

.scene {
  perspective: 1000px;
}

.scene * {
  transform-style: preserve-3d;
}

Everything looks a little janky, but that’s expected until we add a bit more CSS to make it look cool.

We need to calculate the offset distance between each of the stacked layers, that is, the distance each layer will have against each other in order for it to appear together or completely separated.

Illustration of layered blocks showing layer offsets in a 3D perspective with a gradient background.

On the image container, we set two variables: the offset distance to be just 2px and the total layers. These would be used to calculate the offset on the Z-axis and the colors between them to make it appear as a single whole 3D element.

.image-container{
  ...
  --layers-count: 35;
  --layer-offset: 2.5px;
}

That’s not all, we now calculate the distance between each layer using the index --i and the offset on the translateZ() function inside the layer class:

.layer {
  transform: translateZ(calc(var(--i) * var(--layer-offset)));
  ...
}

The next step is to use a normalized value (because the index would be too big) to calculate how dark and saturated we want each image to be, so it appears darker in 3D as it goes down in index value. i.e:

.layer {
  ...
  --n: calc(var(--i) / var(--layers-count));
  filter: 
    brightness(calc(0.4 + var(--n) * 0.8))
    saturate(calc(0.8 + var(--n) * 0.4));
}

I’m adding 0.4 to the multiplied value of 80% and --n. If --n is 2/35 for example, our brightness value would equal to 0.45 (0.4 + 2/36 x 0.8) and the saturation would be equal to 0.83. If --``n is 3/35, the brightness value would be 0.47, while the saturation would be 0.82 and so on.

And that’s it! We’re all set! (sike! Not yet).

We just need to set the position property to absolute and inset to be 0 for all the layers so they can be on top of each other. Don’t forget to set the height and width to any desired length, and the position property of the image-container class to relative while you’re at it. Here’s the code if you’ve been following:

.image-container {
  position: relative;
  width: 300px;
  height: 300px;
  transform: rotateX(20deg) rotateY(-10deg);
  --layers-count: 35;
  --layer-offset: 2.5px;
}

.layers,
.layer {
  position: absolute;
  inset: 0;
}

.layer {
  transform: translateZ(calc(var(--i) * var(--layer-offset)));
  --n: calc(var(--i) / var(--layers-count));
  filter: 
    brightness(calc(0.4 + var(--n) * 0.8))
    saturate(calc(0.8 + var(--n) * 0.4));
}

Here’s a quick breakdown of the mathematical calculations going on:

  • translateZ() makes the items stacked visible by calculating them based on their index multiplied by --layer-offset. This moves it away from the user, which is our main 3D affect here.
  • --n is used to normalize the index to a 0-1 range
  • filter is then used with --n to calculate the saturation and brightness of the 3D element

That’s actually where most of the logic lies. This next part is just basic sizing, positioning, and polish.

.layer img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 20px;
  display: block;
}

.original {
  position: relative;
  z-index: 1;
  width: 18.75rem;
  height: 18.75rem;
}

.original img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 20px;
  display: block;
  box-shadow: 0 20px 60px rgba(0 0 0 / 0.6);
}

Check out the final result. Doesn’t it look so cool?!

We’re not done yet!

Who’s ready for a little bit more interactivity? 🙋🏾 I know I am. Let’s add a rotation animation to emphasize the 3D affet.

.image-container {
  ...
  animation: rotate3d 8s ease-in-out infinite alternate; 
}

@keyframes rotate3d {
  0% {
    transform: rotateX(-20deg) rotateY(30deg);
  }
  100% {
    transform: rotateX(-15deg) rotateY(-40deg);
  }
}

Our final result looks like this! Isn’t this so cool?

Bonus: Adding a control feature

Remember how this article is about images and not gradients? Although the image used was an image of a gradient, I’d like to take things a step further by being able to control things like perspective, layer offset, and its rotation. The bonus step is adding a form of controls.

We first need to add the boilerplate HTML and styling for the controls:

 <div class="controls">
  <h3>3D Controls</h3>
  <label>Perspective: <span id="perspValue">1000px</span></label>
  <input type="range" id="perspective" min="200" max="2000" value="1000">

  <label>Layer Offset: <span id="offsetValue">2px</span></label>
  <input type="range" id="offset" min="0.5" max="5" step="0.1" value="2">

  <label>Rotate X: <span id="rotXValue">20°</span></label>
  <input type="range" id="rotateX" min="-90" max="90" value="20">

  <label>Rotate Y: <span id="rotYValue">-10°</span></label>
  <input type="range" id="rotateY" min="-90" max="90" value="-10">

  <div class="image-selector">
    <label>Try Different Images:</label>
    <button data-img="https://images.unsplash.com/photo-1579546929518-9e396f3cc809" class="active">Abstract Gradient</button>
    <button data-img="https://images.unsplash.com/photo-1506905925346-21bda4d32df4">Mountain Landscape</button>
    <button data-img="https://images.unsplash.com/photo-1518791841217-8f162f1e1131">Cat Portrait</button>
    <button data-img="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05">Foggy Forest</button>
  </div>
</div>

This would give us access to a host of images to select from, and we would also be able to rotate the main 3D element as we please using <input> type range and <button>s.

The CSS is to add basic styles to the form controls. Nothing too complicated:

.controls {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  position: absolute;
  top: 1.2rem;
  right: 1.2rem;
  background: rgba(255, 255, 255, 0.1);
  backdrop-filter: blur(10px);
  padding: 1.15rem;
  height: 20rem;
  border-radius: 10px;
  overflow-y: scroll;
  color: white;
  max-width: 250px;
}

.controls h3 {
  margin-bottom: 15px;
  font-size: 1.15rem;
}

.controls label {
  display: flex;
  justify-content: space-between;
  gap: 0.5rem;
  margin: 15px 0 5px;
  font-size: 0.8125rem;
  font-weight: 500;
}

.controls input {
  width: 100%;
}

.controls span {
  font-weight: bold;
}

.image-selector {
  margin-top: 20px;
  padding-top: 20px;
  border-top: 1px solid rgb(255 255 255 / 0.2);
}

.image-selector button {
  width: 100%;
  padding: 8px;
  margin: 5px 0;
  background: rgb(255 255 255 / 0.2);
  border: 1px solid rgb(255 255 255 / 0.3);
  border-radius: 5px;
  color: white;
  cursor: pointer;
  font-size: 12px;
  transition: all 0.3s;
}

.image-selector button:hover {
  background: rgb(255 255 255 / 0.3);
}

.image-selector button.active {
  background: rgb(255 255 255 / 0.4);
  border-color: white;
}

This creates the controls like we want. We haven’t finished, though. Try making some adjustments, and you’d notice that it doesn’t do anything. Why? Because we haven’t applied any JS!

The code below would affect the rotation values on the x and y axis, layer offset, and perspective. It would also change the images to any of the other 3 specified:

const scene = document.querySelector(".scene");
const container = document.querySelector(".image-container");

document.getElementById("perspective").addEventListener("input", (e) => {
  const val = e.target.value;
  scene.style.perspective = val + "px";
  document.getElementById("perspValue").textContent = val + "px";
});

document.getElementById("offset").addEventListener("input", (e) => {
  const val = e.target.value;
  container.style.setProperty("--layer-offset", val + "px");
  document.getElementById("offsetValue").textContent = val + "px";
});

document.getElementById("rotateX").addEventListener("input", (e) => {
  const val = e.target.value;
  updateRotation();
  document.getElementById("rotXValue").textContent = val + "°";
});

document.getElementById("rotateY").addEventListener("input", (e) => {
  const val = e.target.value;
  updateRotation();
  document.getElementById("rotYValue").textContent = val + "°";
});

function updateRotation() {
  const x = document.getElementById("rotateX").value;
  const y = document.getElementById("rotateY").value;
  container.style.transform = `rotateX(${x}deg) rotateY(${y}deg)`;
}

// Image selector
document.querySelectorAll(".image-selector button").forEach((btn) => {
  btn.addEventListener("click", () => {
    const imgUrl = btn.dataset.img;

    // Update all images
    document.querySelectorAll("img").forEach((img) => {
      img.src = imgUrl;
    });

    // Update active button
    document
      .querySelectorAll(".image-selector button")
      .forEach((b) => b.classList.remove("active"));
    btn.classList.add("active");
  });
});

Plus we pop into the CSS and remove the animation, as we can control it ourselves now. Viola! We have a full working demo with various form controls and an image change feature. Go on, change the image to something else to view the result.

Bonus: 3D CSS… Steak

Using this same technique, you know what else we can build? a 3D CSS steak!

It’s currently in black & white. Let’s make it show some color, shall we?

Summary of things I’m doing to make this work:

  • Create a scene, adding the CSS perspective property
  • Duplicate a single image into separate containers
  • Apply transform-style’s preserve-3d on all divs to position them in the 3D space
  • Calculate the normalized value of all items by dividing the index by the total number of images
  • Calculate the brightness of each image container by multiplying the normalized value by 0.9
  • Set translateZ() based on the index of each element multiplied by an offset value. i.e in my case, it is 1.5px for the first one and 0.5px for the second, and that’s it!!

That was fun! Let me know if you’ve done this or tried to do something like it in your own work before.

]]>
https://frontendmasters.com/blog/how-to-create-3d-images-in-css-with-the-layered-pattern/feed/ 5 7802
Playing with Raster to SVG to 3D Tools https://frontendmasters.com/blog/playing-with-raster-to-svg-to-3d-tools/ https://frontendmasters.com/blog/playing-with-raster-to-svg-to-3d-tools/#comments Fri, 09 Feb 2024 18:07:22 +0000 https://frontendmasters.com/blog/?p=773 I happen to have bookmarked a few new-to-me SVG tools that all seemed to fit together in interesting ways, so I thought I would have a play and share.

Raster to SVG

One type of these tools is Raster-to-SVG. That is, taking something like a photo and “vectorizing” it. I happen to have my multivitamin bottle on my desk, so I “drew” that (no laughing).

Now I want that in vector. So my first step was SVGcode, a Progressive Web App (PWA) from Thomas Steiner that I think is pretty cool. (Feels in the spirit of SVGOMG for optimization that I also think is cool.) First I took a “better” straight on photo of the “drawing”.

The I dropped this on SVGcode. It died. That is, it “spun” for a while then ultimately threw some “Out of Bounds” error. I think the graphic was just too big. All I did was crop it down to closer around the drawings edges and tried again, and then it worked. After fiddling all the knobs, this was the best I could do.

There must have been too much shading on the photo on the paper so that crud at the bottom right seemed unavoidable. Certainly a cleaner picture (perhaps even a good scan) would have avoided this. That stuff is pretty easy to grab and clean up in something like Adobe Illustrator.

But — if you’re going to use Illustrator, might as well us it’s raster-to-vector abilities anyway. I tried that, just dragging the image onto a new document and clicking the Image Trace button. This ended up cleaner was faster than SVGcode (but, SVGcode is free, and Illustrator is expensive, so there’s that.)

I also had Vectorizer.AI on my list to try, and this was the result there:

It’s clearly designed for full color raster images and conversion and had no options to control anything. Certainly worth trying if you’re shooting for that look.

SVG to 3D

The other tools on my list was this Vector to 3D tool. So all I did was upload the nicest vectorized copy I had, and…

Ha! There was a solid white background behind my SVG, so I had to go and delete that. But the look with just lines wasn’t really doing much, so I fiddled with the SVG such that it was transparent around the object, but the objects had backgrounds. Then I uploaded that and fiddled with the controls and got this!

I think that’s pretty darn cool! There is a lot of controls on this app, and more to unlock with paid plans, so that’s certainly worth checking out.

The other 3D tool I had bookmarked that took SVG was design.glyf.space. I took my same SVG asset, the one with the colored backgrounds, and dropped it on there, choosing the simplest looking model first. This is probably easiest to understand as a video since the model I picked kinda went with stacked flat layers:

This tool is more in the AI-weirdness category so you’ll have to have a good play with the different possibilities there.

I don’t even know what to tell you there.

]]>
https://frontendmasters.com/blog/playing-with-raster-to-svg-to-3d-tools/feed/ 1 773