Do you know those completely flat corrugated boxes? You can fold them and tape them to make a practical box. When recycle is needed, cut them open and flatten them. Recently, I was consulted with a 3D animation version of this concept and I thought it would be an interesting tutorial to implement it with pure CSS, so we got started!
What would this kind of animation look like? How do we create a wrapping timeline? Can the size be adjusted flexibly? Let's create a pure CSS package toggle effect.
The final effect is as follows: click to pack and unpack the carton.
Where to start? It is best to make plans in advance. We know we need a wrapping template and we need to fold it in three-dimensional space. If you are not familiar with CSS 3D, I suggest you read this article to get started.
If you are familiar with 3D CSS, you may tend to build a cuboid and then start. However, this brings some problems. We need to consider how the package is converted from 2D to 3D.
Let's start by creating a template. We need to plan the markings in advance and think about how we want the packaging animation to work. Let's start with some HTML.
<div> <div> <div> <div> <div></div> <div></div> <div> <div></div> <div></div> </div> <div> <div></div> <div></div> <div> <div></div> <div></div> </div> </div> </div> </div> </div> </div>
There is a lot of content here. There are many divs. I often like to use Pugs to generate tags so that the content can be divided into reusable chunks. For example, each side has two covers. We can create a Pug mixin for the side and apply modifier class name using properties to make all these tags easier to write.
mixin flaps() .package__flap.package__flap--top .package__flap.package__flap--bottom mixin side(class) .package__side(class=`package__side--${class || 'side'}`) Flaps() Block .scene .package__wrapper .package side('main') side('extra') side('flipped') side('tabbed')
We used two mixins. One creates a cover for each side of the box. Another creates the side of the box. Note that in the side mixin we use block. The child elements of the mixin usage are presented here, which is especially useful because we need to nest some sides later to simplify our work.
Generated tags:
<div class="scene"> <div class="package__wrapper"> <div class="package"> <div class="package__side package__side--main"> <div class="package__flap package__flap--top"></div> <div class="package__flap package__flap--bottom"></div> <div class="package__side package__side--extra"> <div class="package__flap package__flap--top"></div> <div class="package__flap package__flap--bottom"></div> </div> </div> <div class="package__side package__side--flipped"> <div class="package__flap package__flap--top"></div> <div class="package__flap package__flap--bottom"></div> <div class="package__side package__side--tabbed"> <div class="package__flap package__flap--top"></div> <div class="package__flap package__flap--bottom"></div> </div> </div> </div> </div> </div>
Nested sides make folding wrap easier. Just like there are two covers on each side. The side child elements can inherit the side transformation and then apply their own transformation. If we start with a cuboid, it is difficult to take advantage of this.
Check out this demo, which switches between nested and non-nested elements to see the actual effect.
Each box has a transform-origin set to the bottom right corner with a value of 100% 100%. Selecting the "Transform" toggle will rotate each box by 90 degrees. However, note that if we nest elements, the behavior of that transformation changes.
We switched the tags for both versions, but nothing else was changed.
Nesting:
<div> <div> <div> <div> <div></div> </div> </div> </div> </div>
Non-Nested:
<div> <div></div> <div></div> <div></div> <div></div> </div>
After applying some styles to our HTML, we have our package template.
The style specifies different colors and locates the sides to the package. Each side is positioned relative to the "main" side. (You will soon understand why nesting is so useful.)
Some things need to be paid attention to. Just like using a cuboid, we use --height
, --width
and --depth
variables to determine the size. This will make it easier for us to change the package size later on.
.package { height: calc(var(--height, 20) * 1vmin); width: calc(var(--width, 20) * 1vmin); }
Why define the size in this way? We used a unitless default size of 20, an idea I got from Lea Verou's 2016 CSS ConfAsia talk (starting at 52:44). Use custom properties as "data" instead of "value", we can use them as we like using calc()
. Also, JavaScript doesn't have to care about the unit of value, we can change it to pixels, percentages, etc. without changing it elsewhere. You can refactor it to coefficients in --root
, but this can quickly become overly complex.
The cover plates on each side also need to be slightly smaller than the sides to which they belong. This way, when we fold them, there is no z-index conflict.
.package__flap { width: 99.5%; height: 49.5%; background: var(--flap-bg, var(--face-4)); position: absolute; left: 50%; transform: translate(-50%, 0); } .package__flap--top { transform-origin: 50% 100%; bottom: 100%; } .package__flap--bottom { top: 100%; transform-origin: 50% 0%; } .package__side--extra > .package__flap--bottom, .package__side--tabbed > .package__flap--bottom { top: 99%; } .package__side--extra > .package__flap--top, .package__side--tabbed > .package__flap--top { bottom: 99%; }
We also began to consider transform-origin
for each component. The top cover will rotate from its bottom edge and the bottom cover will rotate from its top edge.
We can use pseudo-elements to create the label on the right. We use clip-path
to get the desired shape.
.package__side--tabbed:after { content: ''; position: absolute; left: 99.5%; height: 100%; width: 10%; background: var(--face-3); -webkit-clip-path: polygon(0 0%, 100% 20%, 100% 80%, 0 100%); clip-path: polygon(0 0%, 100% 20%, 100% 80%, 0 100%); transform-origin: 0% 50%; }
Let's start using our templates on a 3D plane. We can first rotate .scene
on the X-axis and Y-axis.
.scene { transform: rotateX(-24deg) rotateY(-32deg) rotateX(90deg); }
We are ready to start folding our templates! Our templates will be collapsed according to custom properties --packaged
. If the value is 1, the template can be collapsed. For example, let's collapse some side and pseudo-element labels.
.package__side--tabbed, .package__side--tabbed:after { transform: rotateY(calc(var(--packaged, 0) * -90deg)); } .package__side--extra { transform: rotateY(calc(var(--packaged, 0) * 90deg)); }
Alternatively, we can write a rule for all sides that are not the "main" side.
.package__side:not(.package__side--main), .package__side:not(.package__side--main):after { transform: rotateY(calc((var(--packaged, 0) * var(--rotation, 90)) * 1deg)); } .package__side--tabbed { --rotation: -90; }
This will cover all sides.
Remember when I said that nested sides allow us to inherit the transformation of the parent element? If we update our demo so that we can change the value of --packaged
, we can see how that value affects the transformation. Try sliding the value of --packaged
between 1 and 0 and you'll see what I mean.
Now that we have a way to switch the folded state of the template, we can start to deal with some movement. Our previous demonstration switches between two states. We can use transition
for this. The fastest way? Add a transition
transform
of each child element in .scene
.
.scene *, .scene *::after { transition: transform calc(var(--speed, 0.2) * 1s); }
But we don't fold the entire template at once - in real life, the folding process is in sequence, we will fold one side and its cover first, then move on to the next one, and so on. Scope custom properties are ideal for this purpose.
.scene *, .scene *::after { transition: transform calc(var(--speed, 0.2) * 1s) calc((var(--step, 1) * var(--delay, 0.2)) * 1s); }
Here we say that for each transformation, use transition-delay
to multiply --step
by --delay
. --delay
value does not change, but each element can define its "steps" in the sequence. We can then clearly state the order in which things happen.
.package__side--extra { --step: 1; } .package__side--tabbed { --step: 2; } .package__side--flipped, .package__side--tabbed::after { --step: 3; }
Check out the demo below to better understand how it works. Change the slider value to update the order in which things happen. Can you change which car wins?
The same technology is the key to what we need to do next. We could even introduce a --initial-delay
, adding a slight pause to everything for a more realistic effect.
.race__light--animated, .race__light--animated:after, .car { animation-delay: calc((var(--step, 0) * var(--delay-step, 0)) * 1s); }
If we look back at our package, we can go a step further and apply the "steps" to all the elements that will be converted. This is quite verbose, but it does work. Alternatively, you can inline these values in the tag.
.package__side--extra > .package__flap--bottom { --step: 4; } .package__side--tabbed > .package__flap--bottom { --step: 5; } .package__side--main > .package__flap--bottom { --step: 6; } .package__side--flipped > .package__flap--bottom { --step: 7; } .package__side--extra > .package__flap--top { --step: 8; } .package__side--tabbed > .package__flap--top { --step: 9; } .package__side--main > .package__flap--top { --step: 10; } .package__side--flipped > .package__flap--top { --step: 11; }
However, it feels unrealistic.
If I fold the box in real life, I might flip the box over first and then fold the top cover. How can we do this? Well, those with sharp eyes may have noticed the .package__wrapper
element. We will use it to slide the package. Then we rotate the wrap around the x-axis. This will give the impression of turning the package to the side.
.package { transform-origin: 50% 100%; transform: rotateX(calc(var(--packaged, 0) * -90deg)); } .package__wrapper { transform: translate(0, calc(var(--packaged, 0) * -100%)); }
Adjust the --step
declaration accordingly and we can get an effect similar to this.
If you switch between folded and unfolded states, you will notice that the expansion looks wrong. The expansion order should be the complete reverse of the collapse order. We can flip --step
according to --packaged
and the number of steps. Our latest step is 15. We can update our conversion to this:
.scene *, .scene *:after { --no-of-steps: 15; --step-delay: calc(var(--step, 1) - ((1 - var(--packaged, 0)) * (var(--step) - ((var(--no-of-steps) 1) - var(--step))))); transition: transform calc(var(--speed, 0.2) * 1s) calc((var(--step-delay) * var(--delay, 0.2)) * 1s); }
This is indeed a lot of calc
to reverse transition-delay
. But, it does work! We must remind ourselves to keep --no-of-steps
value up to date!
We have another option. As we continue on the "Pure CSS" route, we will eventually use the checkbox trick to switch the folded state. We can have two sets of defined "steps", one of which is active when our checkbox is selected. This is certainly a more verbose solution. However, it does allow us to control more granularly.
/* Fold*/ :checked ~ .scene .package__side--extra { --step: 1; } /* Expand*/ .package__side--extra { --step: 15; }
Before we give up on dat.gui in the demo, let's adjust the size of the package. We want to check if our package remains centered when folded and flipped. In this demo, the wrap has a larger --height
, while .scene
has a dotted border.
We might as well adjust our transformation to better center the package:
/* Consider the package height by translation on the z-axis*/ .scene { transform: rotateX(calc(var(--rotate-x, -24) * 1deg)) rotateY(calc(var(--rotate-y, -32) * 1deg)) rotateX(90deg) translate3d(0, 0, calc(var(--height, 20) * -0.5vmin)); } /* Consider the wrap depth by sliding the depth before flipping*/ .package__wrapper { transform: translate(0, calc((var(--packaged, 0) * var(--depth, 20)) * -1vmin)); }
This gives us a reliable centering effect in the scene. But it all depends on personal preference!
Now let's get rid of dat.gui and make it "pure" CSS. To do this, we need to introduce a bunch of controls into HTML. We will use a checkbox to collapse and expand our parcel. We will then use a radio button to select the package size.
<label for="one">S</label> <label for="two">M</label> <label for="three">L</label> <label for="four">XL</label> <input type="checkbox" id="package"> <label for="package" class="open">Open the package</label> <label for="package" class="close">Close the package</label>
In the final demo, we will hide the input and use the tag element. But, now, let's make them all visible first. The trick is to use the sibling combiner (~) when some controls are selected. We can then set the custom property value on .scene
.
#package:checked ~ .scene { --packaged: 1; } #one:checked ~ .scene { --height: 10; --width: 20; --depth: 20; } #two:checked ~ .scene { --height: 20; --width: 20; --depth: 20; } #three:checked ~ .scene { --height: 20; --width: 30; --depth: 20; } #four:checked ~ .scene { --height: 30; --width: 20; --depth: 30; }
Here is a demo with this feature!
Now we can make things look "pretty" and add some extra details. Let's hide all inputs first.
input { position: fixed; top: 0; left: 0; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; }
We can set the size option to the circular button:
.size-label { position: fixed; top: var(--top); right: 1rem; z-index: 3; font-family: sans-serif; font-weight: bold; color: #262626; height: 44px; width: 44px; display: grid; place-items: center; background: #fcfcfc; border-radius: 50%; cursor: pointer; border: 4px solid #8bb1b1; transform: translate(0, calc(var(--y, 0) * 1%)) scale(var(--scale, 1)); transition: transform 0.1s; } .size-label:hover { --y: -5; } .size-label:active { --y: 2; --scale: 0.9; }
We want to be able to click anywhere to switch the folding and expanding of the package. Therefore, our .open
and .close
tags will occupy the entire screen. Want to know why we have two labels? This is a little trick. If we use transition-delay
and zoom in on the corresponding tag, we can hide two tags when the package is converted. This is how we fight against spam clicks (although it won't stop users from pressing the spacebar on the keyboard).
.close, .open { position: fixed; height: 100vh; width: 100vw; z-index: 2; transform: scale(var(--scale, 1)) translate3d(0, 0, 50vmin); transition: transform 0s var(--reveal-delay, calc(((var(--no-of-steps, 15) 1) * var(--delay, 0.2)) * 1s)); } #package:checked ~ .close, .open { --scale: 0; --reveal-delay: 0s; } #package:checked ~ .open { --scale: 1; --reveal-delay: calc(((var(--no-of-steps, 15) 1) * var(--delay, 0.2)) * 1s); }
Check out this demo to see where we added background-color
in .open
and .close
. During the conversion, neither label is visible.
We already have full functionality! However, our package is a bit bland at the moment. Let's add extra details to make it look more like a "box" such as wrapping tape and packaging labels.
Details like this are limited only by our imagination! We can use our --packaged
custom property to influence anything. For example, .package__tape
is converting the scaleY
transformation:
.package__tape { transform: translate3d(-50%, var(--offset-y), -2px) scaleX(var(--packaged, 0)); }
It should be noted that whenever we add new features that affect the sequence, we need to update our steps. Not only --step
value, but also --no-of-steps
value.
This is how to create a pure CSS 3D parcel switching. Would you put it on your website? Not likely! However, it's fun to see that you can implement these things with CSS. Custom properties are very powerful.
Why not celebrate and give out a gift for CSS!
Stay good! ʕ •ᴥ•ʔ
The above is the detailed content of How to Make a Pure CSS 3D Package Toggle. For more information, please follow other related articles on the PHP Chinese website!