Print.css but not how you know it – Creating a 3D CSS Printer
Jhey Tompkins
Posted on November 8, 2021
For a while now I've been creating these 3D scenes with CSS for fun. Usually on my live stream.
Each demo is an opportunity to try something different or work out ways to do things with CSS. One thing I often do is take suggestions for what we should try and make on the stream. A recent suggestion was a 3D printer. As in a "3D" printer as opposed to an ink/laserjet. And here's what I put together!
Making things 3D with CSS
I've wrote about making things 3D with CSS before. The general gist is that most scenes are a composition of cuboids.
To make a cuboid, we can use CSS transforms to position the sides of a cuboid. The magic property being transform-style
. Setting this to preserve-3d
allows us to transform elements on the third dimension.
* {
transform-style: preserve-3d;
}
Once you create a few of these scenes, you start picking up ways to speed things up. I like to use Pug as a HTML preprocessor. The mixin ability gives me a way to create cuboids speedier. The markup examples in this article use Pug. But, for each CodePen demo you can use the “View Compiled HTML” option to see the HTML output.
mixin cuboid()
.cuboid(class!=attributes.class)
- let s = 0
while s < 6
.cuboid__side
- s++
Using this code
+cuboid()(class="printer__top")
Would produce
<div class="cuboid printer__top">
<div class="cuboid__side"></div>
<div class="cuboid__side"></div>
<div class="cuboid__side"></div>
<div class="cuboid__side"></div>
<div class="cuboid__side"></div>
<div class="cuboid__side"></div>
</div>
Then I have a set block of CSS I use to lay out the cuboids. The joy here is that we can leverage CSS custom properties to define the properties of a cuboid. As shown in the video above.
.cuboid {
// Defaults
--width: 15;
--height: 10;
--depth: 4;
height: calc(var(--depth) * 1vmin);
width: calc(var(--width) * 1vmin);
transform-style: preserve-3d;
position: absolute;
font-size: 1rem;
transform: translate3d(0, 0, 5vmin);
}
.cuboid > div:nth-of-type(1) {
height: calc(var(--height) * 1vmin);
width: 100%;
transform-origin: 50% 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotateX(-90deg) translate3d(0, 0, calc((var(--depth) / 2) * 1vmin));
}
.cuboid > div:nth-of-type(2) {
height: calc(var(--height) * 1vmin);
width: 100%;
transform-origin: 50% 50%;
transform: translate(-50%, -50%) rotateX(-90deg) rotateY(180deg) translate3d(0, 0, calc((var(--depth) / 2) * 1vmin));
position: absolute;
top: 50%;
left: 50%;
}
.cuboid > div:nth-of-type(3) {
height: calc(var(--height) * 1vmin);
width: calc(var(--depth) * 1vmin);
transform: translate(-50%, -50%) rotateX(-90deg) rotateY(90deg) translate3d(0, 0, calc((var(--width) / 2) * 1vmin));
position: absolute;
top: 50%;
left: 50%;
}
.cuboid > div:nth-of-type(4) {
height: calc(var(--height) * 1vmin);
width: calc(var(--depth) * 1vmin);
transform: translate(-50%, -50%) rotateX(-90deg) rotateY(-90deg) translate3d(0, 0, calc((var(--width) / 2) * 1vmin));
position: absolute;
top: 50%;
left: 50%;
}
.cuboid > div:nth-of-type(5) {
height: calc(var(--depth) * 1vmin);
width: calc(var(--width) * 1vmin);
transform: translate(-50%, -50%) translate3d(0, 0, calc((var(--height) / 2) * 1vmin));
position: absolute;
top: 50%;
left: 50%;
}
.cuboid > div:nth-of-type(6) {
height: calc(var(--depth) * 1vmin);
width: calc(var(--width) * 1vmin);
transform: translate(-50%, -50%) translate3d(0, 0, calc((var(--height) / 2) * -1vmin)) rotateX(180deg);
position: absolute;
top: 50%;
left: 50%;
}
Using custom properties, we can control various characteristics of the cuboids, etc.
-
--width
: The width of a cuboid on the plane -
--height
: The height of a cuboid on the plane -
--depth
: The depth of a cuboid on the plane -
--x
: The X position on the plane -
--y
: The Y position on the plane
This isn't very impressive until we put the cuboid into a scene and rotate it. Again, I use custom properties to manipulate the scene whilst I work on making something. Dat.GUI comes in super handy here.
If you inspect the demo, using the control panel updates custom CSS properties on the scene. This scoping of CSS custom properties saves a lot of repeated code and keeps things DRY.
More Than One Way
Much like many things in CSS, there's more than one way to do it. Often you can compose a scene from cuboids and position things as and when you need. It can get tricky to manage though. Often there's a need to group things or add some type of container.
Consider this example where the chair is it’s own sub-scene that can be moved around.
Many recent examples aren’t as complex. I've been reaching for extrusion. This means I'm able to map out whatever I'm making in 2D elements. For example, a helicopter I recently created.
.helicopter
.helicopter__rotor
.helicopter__cockpit
.helicopter__base-light
.helicopter__chair
.helicopter__chair-back
.helicopter__chair-bottom
.helicopter__dashboard
.helicopter__tail
.helicopter__fin
.helicopter__triblade
.helicopter__tail-light
.helicopter__stabilizer
.helicopter__skids
.helicopter __skid--left.helicopter__ skid
.helicopter __skid--right.helicopter__ skid
.helicopter__wing
.helicopter __wing-light.helicopter__ wing-light--left
.helicopter __wing-light.helicopter__ wing-light--right
.helicopter__launchers
.helicopter __launcher.helicopter__ launcher--left
.helicopter __launcher.helicopter__ launcher--right
.helicopter__blades
Then we can drop cuboids into all the containers using the mixin. Then apply a required "thickness" to each cuboid. The thickness becomes dictated by scoped custom properties. This demo toggles the --thickness
property for cuboids that make the helicopter. It gives an idea of what the 2D mapping looked like to start with.
That's the gist of how to go about making 3D things with CSS. Digging into the code will unveil some tricks for sure. But, in general, scaffold a scene, populate with cuboids, and color the cuboids. You'll often want some different shades of a color so we can differentiate the sides of a cuboid. Any extra details are either things that we can add to a cuboid side or transforms we can apply to a cuboid. For example, rotating and moving on the Z axis.
Let’s consider a stripped down example.
.scene
.extrusion
+cuboid()(class="extrusion__cuboid")
The new CSS for creating a cuboid with extrusion could look like this. Note how we're including scoped custom properties for the color of each side too. It would be wise to drop some defaults under the :root
here or fallback values.
.cuboid {
width: 100%;
height: 100%;
position: relative;
}
.cuboid__side:nth-of-type(1) {
background: var(--shade-one);
height: calc(var(--thickness) * 1vmin);
width: 100%;
position: absolute;
top: 0;
transform: translate(0, -50%) rotateX(90deg);
}
.cuboid__side:nth-of-type(2) {
background: var(--shade-two);
height: 100%;
width: calc(var(--thickness) * 1vmin);
position: absolute;
top: 50%;
right: 0;
transform: translate(50%, -50%) rotateY(90deg);
}
.cuboid__side:nth-of-type(3) {
background: var(--shade-three);
width: 100%;
height: calc(var(--thickness) * 1vmin);
position: absolute;
bottom: 0;
transform: translate(0%, 50%) rotateX(90deg);
}
.cuboid__side:nth-of-type(4) {
background: var(--shade-two);
height: 100%;
width: calc(var(--thickness) * 1vmin);
position: absolute;
left: 0;
top: 50%;
transform: translate(-50%, -50%) rotateY(90deg);
}
.cuboid__side:nth-of-type(5) {
background: var(--shade-three);
height: 100%;
width: 100%;
transform: translate3d(0, 0, calc(var(--thickness) * 0.5vmin));
position: absolute;
top: 0;
left: 0;
}
.cuboid__side:nth-of-type(6) {
background: var(--shade-one);
height: 100%;
width: 100%;
transform: translate3d(0, 0, calc(var(--thickness) * -0.5vmin)) rotateY(180deg);
position: absolute;
top: 0;
left: 0;
}
We've gone with three shades for this example. But, sometimes you may need more. This demo puts that together but allows you to change scoped custom properties. The "thickness" value will change the extrusion of the cuboid. The transforms and dimensions will affect the containing element with the class "extrusion".
Scaffolding a Printer
To start, we can scaffold out all the pieces we need. With practice this becomes more obvious. But, the general rule is trying to visualise everything as boxes. That gives you a good idea of how to break something up.
.scene
.printer
.printer __side.printer__ side--left
.printer __side.printer__ side--right
.printer __tray.printer__ tray--bottom
.printer __tray.printer__ tray--top
.printer__top
.printer__back
If you visualise the aim of what we're going for here. The two side pieces leave a gap in the middle. Then we have a cuboid that sits across the top and one that fills the back. Then two cuboids to make up the paper tray.
Once you're at that stage, it's a case of populating the cuboids which looks like this.
.scene
.printer
.printer __side.printer__ side--left
+cuboid()(class="cuboid--side")
.printer __side.printer__ side--right
+cuboid()(class="cuboid--side")
.printer __tray.printer__ tray--bottom
+cuboid()(class="cuboid--tray")
.printer __tray.printer__ tray--top
+cuboid()(class="cuboid--tray")
.printer__top
+cuboid()(class="cuboid--top")
.printer__back
+cuboid()(class="cuboid--back")
Note how we're able to reuse the classnames such as cuboid--side
. These cuboids are likely to be the same thickness and use the same colors. Their position and size gets dictated by the containing element.
Piecing it together, we can can get something like this.
Exploding the demo shows the different cuboids that make up the printer. If you turn off the extrusion, you can see the flat containing elements.
Adding Some Detail
Now. You may have noticed that there's more detail than what adding colors to each side would provide. And this comes down to finding ways to add extra detail. We've got different options depending on what we want to add.
If it's an image or some basic color changes, we can make use of background-image
to layer up gradients, etc.
For example, the top of the printer has details and the opening of the printer. This code addresses the top side of the top cuboid. The gradient handles the opening of the printer and the details.
.cuboid--top {
--thickness: var(--depth);
--shade-one: linear-gradient(#292929, #292929) 100% 50%/14% 54% no-repeat, linear-gradient(var(--p-7), var(--p-7)) 40% 50%/12% 32% no-repeat, linear-gradient(var(--p-7), var(--p-7)) 30% 50%/2% 12% no-repeat, linear-gradient(var(--p-3), var(--p-3)) 0% 50%/66% 50% no-repeat, var(--p-1);
}
For the bear logo, we could use a background-image
or even reach for a pseudo-element and position it.
.cuboid--top > div:nth-of-type(1):after {
content: '';
position: absolute;
top: 7%;
left: 10%;
height: calc(var(--depth) * 0.12vmin);
width: calc(var(--depth) * 0.12vmin);
background: url("https://assets.codepen.io/605876/avatar.png");
background-size: cover;
transform: rotate(90deg);
filter: grayscale(0.5);
}
If we need to add more extensive details, then we are likely going to have to break out of using our cuboid mixin. For example, the top of our printer is going to have a preview screen using an img
element.
.printer__top
.cuboid.cuboid--top
.cuboid__side
.cuboid__side
.cuboid__side
.cuboid__side
.screen
.screen__preview
img.screen__preview-img
.cuboid__side
.cuboid__side
Add some more details and we're ready to get some paper in the mix!
Paper Journey
What's a printer without some paper? We want to animate some paper flying into the printer and getting shot out the other end.
We want something like this demo. Click anywhere to see a piece of paper fed into the printer and printed.
We can add a block of paper to the scene with a cuboid and then use a separate element to act as a single sheet of paper.
.paper-stack.paper-stack--bottom
+cuboid()(class="cuboid--paper")
.paper-stack.paper-stack--top
.cuboid.cuboid--paper
.cuboid__side
.paper
.paper__flyer
.cuboid__side
.cuboid__side
.cuboid__side
.cuboid__side
.cuboid__side
But, animating the paper flying into the printer takes some trial and error. It's wise to play with different transforms in the DevTools inspector. This is a good way to see how things will look. Often, it’s easier to use wrapper elements too. We use the .paper
element to make the transfer and then use .paper__flyer
to animate feeding the paper.
:root {
--load-speed: 2;
}
.paper-stack--top .cuboid--paper .paper {
animation: transfer calc(var(--load-speed) * 0.5s) ease-in-out forwards;
}
.paper-stack--top .cuboid--paper .paper__flyer {
animation: fly calc(var(--load-speed) * 0.5s) ease-in-out forwards;
}
.paper-stack--top .cuboid--paper .paper__flyer:after {
animation: feed calc(var(--load-speed) * 0.5s) calc(var(--load-speed) * 0.5s) forwards;
}
@keyframes transfer {
to {
transform: translate(0, -270%) rotate(22deg);
}
}
@keyframes feed {
to {
transform: translate(100%, 0);
}
}
@keyframes fly {
0% {
transform: translate3d(0, 0, 0) rotateY(0deg) translate(0, 0);
}
50% {
transform: translate3d(140%, 0, calc(var(--height) * 1.2)) rotateY(-75deg) translate(180%, 0);
}
100% {
transform: translate3d(140%, 0, var(--height)) rotateY(-75deg) translate(0%, 0) rotate(-180deg);
}
}
You'll notice that there's a fair bit of calc
usage in there. To compose the animation timeline we can make use of CSS custom properties. Referring to a property, we can calculate the correct delays for each animation in the chain. The paper transfers and flies at the same time. One animation handles moving the container, another handles rotating the paper. Once those animations end, the paper gets fed into the printer with the feed
animation. The animation delay is equal to the duration of the first two animations that run at the same time.
Run this demo where I’ve colored the container elements red and green. We make use of .paper__flyer
's pseudo-element to represent the piece of paper. But, the container elements do the hard work.
You may be wondering when the paper comes out at the other end. But, in fact, the paper isn't the same element throughout. We use one element to go into the printer. And another element for the paper when it flies out of the printer. Another instance where extra elements will make our life easier.
The paper uses more than one element to do the loop and then the paper gets positioned to the edge of that element. Running this demo with more colored container elements shows how it's working.
Once again, it's a bit of trial and error plus thinking about how we can leverage the use of container elements. Having a container with an offset transform-origin
allows us to create the loop.
Printing
We have everything in place. Now it's a case of actually printing something. To do this, we're going to add a form that allows users to pass in the URL of an image.
form.customer-form
label(for="print") Print URL
input#print(type='url' required placeholder="URL for Printing")
input(type="submit" value="Print")
With some styling, we get something like this.
The native behavior of forms and the use of required
and type="url"
means we only accept a URL. We could take this further with a pattern
and check for certain image types. But, some good URL for random images don't include the image type. For example, "https://source.unsplash.com/random".
Submitting our form doesn't behave as we want and also the printing animation runs once on load. A way around this would be to only run the animation when a certain class gets applied to the printer.
When we submit the form, we can make a request for the URL and then set the src
for images in our scene. One image being the screen preview on the printer. The other being an image on one side of the paper. In fact, when we print, we are going to add a new element for each printed piece of paper. That way each print looks like it gets added to a pile. We can remove the piece of paper we have on load.
Let’s start by handling the form submission. We are going to prevent the default event and call a PROCESS
function.
const PRINT = e => {
e.preventDefault()
PROCESS()
}
const PRINT_FORM = document.querySelector('form')
PRINT_FORM.addEventListener('submit', PRINT)
This function will handle making the request for our image source.
let printing = false
const PREVIEW = document.querySelector('img.screen__preview-img')
const SUBMIT = document.querySelector('[type="submit"]')
const URL_INPUT = document.querySelector('[type="url"]')
const PROCESS = async () => {
if (printing) return
printing = true
SUBMIT.disabled = true
const res = await fetch(URL_INPUT.value)
PREVIEW.src = res.url
URL_INPUT.value = ''
}
We also set a printing
variable to true
which we will use to track current state, and disable the form’s button.
The reason we make a request for the image instead of setting it on the image? We want an absolute URL to an image. If we use the "unsplash" URL mentioned above and then share it between the images, this might not work. That's because we can run into scenarios where we have different images displayed.
Once we have the image source, we set the preview image source to that URL and reset the form’s input value.
To trigger the animation, we can hook into the "load" event of our preview image. When the event fires, we create a new element for the piece of paper to print and append it to the printer
element. At the same time, we add a printing
class to our printer. We can use this to trigger the first part of our paper animation.
PREVIEW.addEventListener('load', () => {
PRINTER.classList.add('printing')
const PRINT = document.createElement('div')
PRINT.className = 'printed'
PRINT.innerHTML = `
<div class="printed__spinner">
<div class="printed__paper">
<div class="printed__papiere">
<img class="printed__image" src=${PREVIEW.src}/>
</div>
</div>
<div class="printed__paper-back"></div>
</div>
`
PRINTER.appendChild(PRINT)
// After a set amount of time reset the state
setTimeout(() => {
printing = false
SUBMIT.removeAttribute('disabled')
PRINTER.classList.remove('printing')
}, 4500)
})
After a set amount of time, we can reset the state. An alternative approach would be to debounce a bubbling animationend
event. But, we can use a setTimeout
as we know how long the animation will take.
Our printing isn't to the correct scale though. And that's because we need to scale the image to the piece of paper. We need a small piece of CSS for this.
.printed__image {
height: 100%;
width: 100%;
object-fit: cover;
}
It would also be neat if the lights on the front of the printer communicated that the printer is busy. We could adjust the hue of one of the lights when the printer is printing.
.progress-light {
background: hsla(var(--progress-hue, 104), 80%, 50%);
}
.printing {
--progress-hue: 10; /* Equates to red */
}
Put that together and we’ve got a “working” printer made with CSS and a smidge of JavaScript.
That’s It!
A look at how we can make a functional 3D Printer with CSS, a smidge of JavaScript, and leveraging Pug.
We covered a bunch of different things to achieve this. Some of the things we covered:
- How to make 3D things with CSS
- Using Pug mixins
- Using scoped custom CSS properties to keep things DRY
- Using extrusion to create 3D scenes
- Handling forms with JavaScript
- Composing animation timelines with custom properties
The joy of creating these demos is that many of them pose different problems to overcome. How to create certain shapes or construct certain animations. There’s often more than one way to do something.
What cool things could you make with 3D CSS? I’d love to see!
As always, thanks for reading. Wanna see more? Come find me on Twitter or check out my live stream!
Stay Awesome! ʕ •ᴥ•ʔ
Posted on November 8, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.