When working with 3D transformed elements, you might notice that they don’t have any shading and appear to be very flat. In real life, objects block light and have shadows. Surfaces can be matte, reflective, and everything in between. Indeed, we can do better.

If you need to cast light onto complicated geometry, there are options available such as Photon, but they’re very processor intensive. In this article, I’ll show you a solution that can be applied to objects that only have a few faces. Our example will be a 3D movie gallery with glossy posters and, when the posters are rotated, the sides will contain matte information cards with meta data about the film.

The Markup

Our page is going to have a few different parts. First, we need a simple wrapper to center everything on the page. Next, we’ll create an unordered list that will contain all of our 3D posters. We’ll add the class “stage” to the ul and “scene” to each li . In our CSS, the stage element will act as a container for the scene elements, which will be their own 3D environment with the perspective property applied.

<div class="wrapper"> <ul class="stage"> <li class="scene"></li> <li class="scene"></li> <li class="scene"></li> </ul> </div>

For each li , we need to add quite a bit of markup. This will contain our movie poster as well as all the metadata about each movie. In a more robust movie library, this could be done dynamically with some backend code. We’ll keep things simple for the purposes of this example.

<li class="scene"> <div class="movie"> <div class="poster"></div> <div class="info"> <header> <h1>It's a Wonderful Life</h1> <span class="year">1946</span> <span class="rating">PG</span> <span class="duration">130 minutes</span> </header> <p> In Bedford Falls, New York on Christmas Eve, George Bailey is deeply troubled. Prayers for his well-being from friends and family reach Heaven. Clarence Odbody, Angel Second Class, is assigned to visit Earth to save George, thereby earning his wings. Franklin and Joseph, the head angels, review George's life with Clarence. </p> </div> </div> </li>

Our library will contain two more movies in addition to this one. The metadata is a bit lengthy and there are also a few image assets you’ll need, but everything is included in the code download. Let’s get to styling.

The CSS

Note that the CSS will not contain any vendor prefixes, but you will find them in the files.

First, let’s get some of the basics out of the way. We need to center our wrapper and then remove the default list styling from the stage class.

.wrapper { margin: 0 auto 100px auto; max-width: 960px; } .stage { list-style: none; padding: 0; }

Next, we want to style the scene class with an explicit width and height (the same as our posters). If you’d like to provide additional detail needed for high resolution displays then you could use images that are twice as large.

The margin between each scene will provide sufficient spacing so that they don’t overlap each other. Then we’ll float all of the list items to the left so that they line up next to one another in a nice gallery. This is similar to how most top-level website navigation is created.

Finally, we’ll add the perspective property. This will allow us to create a 3D scene in the nested elements, and the value of 1000px will give the objects a decent amount of depth. A lower value would be a bit too dramatic, but you can experiment with this and see what works best for you.

.scene { width: 260px; height: 400px; margin: 30px; float: left; perspective: 1000px; }

Similar .scene , we also need to set an explicit width and height on .movie . This will help each poster look correct when it’s being transformed. Next, we’ll set the transform-style to preserve-3d so that we can transform elements in 3D space. Finally, we’ll translate it along the Z plane by -130 pixels. This will give the posters a little bit more room to move around and really pop towards the viewer.

.movie { width: 260px; height: 400px; transform-style: preserve-3d; transform: translateZ(-130px); transition: transform 350ms; }

Here’s where the movement happens. We’ll apply a transition to the .movie class. The transition timing is set to a rapid 350ms, but if you’d like a more dramatic effect, you could slow it down.

Then we’ll transform each .movie on :hover . This will rotate the 3D poster along the Y axis and then move it towards the screen along the Z plane. You could rotate the poster by a full 90 degrees, but I prefer to leave it slightly offset to maintain the 3D effect while hovered.

.movie:hover { transform: rotateY(-78deg) translateZ(20px); }

Observant coders will notice that, while we have translated the scene, we never actually rotated any elements that would give semblance of geometry and build an object. Let’s create each 3D poster now. Each .poster and .info card needs to be positioned absolutely, otherwise they’ll push one another out of the way. We don’t want that, because we’re going to position them using transforms. Next, we need to set an explicit dimensions on both of the classes so that each of the two sides is exactly the same.

.movie .poster, .movie .info { position: absolute; width: 260px; height: 400px; background-color: #fff; backface-visibility: hidden; }

With the geometry of our posters ready, we can transform them into place. The .poster just needs to be moved 130px along the Z plane (because remember, we moved the .movie back by this amount). The background size has been set to cover so that when we apply our poster backgrounds, they’ll fill the geometry. They should anyway since they’re sized correctly, so this is really just a precautionary measure.

The .info needs to be translated by the same amount as the .poster , but it also needs to be rotated. We want to form a square box, so we’ll rotate it 90 degrees. I’ve added some styling after the transformation, but this is mostly just for aesthetic reasons.

.movie .poster { transform: translateZ(130px); background-size: cover; background-repeat: no-repeat; } .movie .info { transform: rotateY(90deg) translateZ(130px); border: 1px solid #B8B5B5; font-size: 0.75em; }

We’ll use the pseudo-element ::after to create another face that will have a subtle box shadow beneath the movie box:

.movie::after { content: ''; width: 260px; height: 260px; position: absolute; bottom: 0; box-shadow: 0 30px 50px rgba(0,0,0,0.3); transform-origin: 100% 100%; transform: rotateX(90deg) translateY(130px); transition: box-shadow 350ms; } .movie:hover::after { box-shadow: 20px -5px 50px rgba(0,0,0,0.3); }

Further down in the CSS, we style the metadata contained inside the .info class. None of this is particularly relevant to this demo, as it’s mostly just formatting some text and imagery. The real magic happens later on.

.info header { color: #FFF; padding: 7px 10px; font-weight: bold; height: 195px; background-size: contain; background-repeat: no-repeat; text-shadow: 0px 1px 1px rgba(0,0,0,1); } .info header h1 { margin: 0 0 2px; font-size: 1.4em; } .info header .rating { border: 1px solid #FFF; padding: 0px 3px; } .info p { padding: 1.2em 1.4em; margin: 2px 0 0; font-weight: 700; color: #666; line-height: 1.4em; border-top: 10px solid #555; }

Here’s where we create our pseudo-lighting using the box-shadow property. For the .poster class, we add an inset box shadow with an X and Y offset of 0px . The blur radius will be set to 40px and the shadow is set to rgba(255,255,255,0) (which is white “shadow” set to 100% transparency). Remember, there’s a transition applied to all the children of .movie , so if we set a starting state for the shadow, we can then animate it using a :hover state.

If we :hover over the .movie , it will reset the values of the box-shadow for the .poster and animate them with a transition. In this new state, the poster is still inset, but this time it has an X offset of 300px and an opacity of 0.8 for the white color. This will effectively move the box-shadow over top the poster with some fuzziness along the edge. The transparency will help to gel the shadow with the poster image, which will make the poster look like it has a glossy finish.

.movie .poster, .movie .info, .movie .info header { transition: box-shadow 350ms; } .movie .poster { box-shadow: inset 0px 0px 40px rgba(255,255,255,0); } .movie:hover .poster { box-shadow: inset 300px 0px 40px rgba(255,255,255,0.8); }

Just like for the poster, we also want to apply a shadow to the other side of the box. For the .info panel, we want a dark shadow to disappear as the element is rotating forward and then reappear when the paper texture recedes back into the gap between each 3D poster. On our page, with the glossy poster on the left and the shadow on the right, it will make it appear as though there’s a light source coming from the left side of the page.

.movie .info, .movie .info header { box-shadow: inset -300px 0px 40px rgba(0,0,0,0.5); } .movie:hover .info, .movie:hover .info header { box-shadow: inset 0px 0px 40px rgba(0,0,0,0); }

Last but not least, we need to add the poster images and still preview images to each movie. I’ve done this using the :nth-child pseudo class, but again, this would probably be done elsewhere in a dynamic website.

.scene:nth-child(1) .movie .poster { background-image: url(../img/poster01.jpg); } .scene:nth-child(2) .poster { background-image: url(../img/poster02.jpg); } .scene:nth-child(3) .poster { background-image: url(../img/poster03.jpg); } .scene:nth-child(1) .info header { background-image: url(../img/still01.jpg); } .scene:nth-child(2) .info header { background-image: url(../img/still02.jpg); } .scene:nth-child(3) .info header { background-image: url(../img/still03.jpg); }

You’ll notice that we’ve used Modernizr in the demo to detect support for 3D transforms and provide a simple fallback for browsers that don’t support it.

That’s it! As I suggested in the intro, this technique can be applied to simple geometry. Some of you may be wondering why I didn’t use a gradient to create a more controlled shadow over top the poster images. At the time of this article, transitions cannot be applied to gradients, so while this might work for static geometry, the illusion would be broken as soon as the element is animated.

If you have any questions, comments, or improvements on this technique, I’d love to hear about them in the comments!