1<!doctype html> 2<html lang="en" itemscope itemtype="https://schema.org/Article"> 3<head> 4 <meta charset="utf-8"> 5 <title>Single HTML Element Cube with 6 faces - Leandro Linares</title> 6 <meta name="viewport" content="width=device-width"> 7 <meta name="theme-color" content="#fff"> 8 <meta name="description" content="No CSS was harmed in the making of this blog post."> 9 10 <link rel="icon" href="/favicon.gif"> 11 <link rel="apple-touch-icon" href="/apple-touch-icon.png"> 12 <link rel="manifest" href="/manifest.json"> 13 <link rel="alternate" href="/feed.xml" type="application/rss+xml" title="Leandro Linares"> 14 <link rel="stylesheet" href="/styles.css"> 15 <link rel="stylesheet" href="/assets/lite-yt-embed.css"> 16 <link rel="canonical" href="https://leandrolinares.com/blog/single-element-cube/"> 17 18 <meta itemprop="mainEntityOfPage" content="https://leandrolinares.com/blog/single-element-cube/"> 19 <meta itemprop="inLanguage" content="en"> 20 <meta itemprop="image" content="https://leandrolinares.com/blog/single-element-cube/face-swapping.png"> 21 <meta name="twitter:site" content="@lean8086"> 22 <meta name="twitter:url" content="https://leandrolinares.com/blog/single-element-cube/"> 23 <meta name="twitter:title" content="Single HTML Element Cube with 6 faces"> 24 <meta name="twitter:description" content="No CSS was harmed in the making of this blog post."> 25 <meta name="twitter:creator" content="@lean8086"> 26 <meta name="twitter:card" content="summary_large_image"> 27 <meta name="twitter:image" content="https://leandrolinares.com/blog/single-element-cube/face-swapping.png"> 28 <meta property="og:title" content="Single HTML Element Cube with 6 faces"> 29 <meta property="og:type" content="article"> 30 <meta property="og:url" content="https://leandrolinares.com/blog/single-element-cube/"> 31 <meta property="og:description" content="No CSS was harmed in the making of this blog post."> 32 <meta property="og:site_name" content="Leandro Linares"> 33 <meta property="og:image" content="https://leandrolinares.com/blog/single-element-cube/face-swapping.png"> 34 35 <style> 36 :root { 37 --cube-size: 200px; 38 } 39 40 .experiment-workspace { 41 overflow: hidden; 42 } 43 44 .experiment-workspace .context { 45 margin: var(--cube-size) auto 0; 46 transform-style: preserve-3d; 47 transform: rotateX(70deg) rotateZ(45deg); 48 width: var(--cube-size); 49 height: var(--cube-size); 50 } 51 52 .experiment-workspace b, 53 .experiment-workspace b::before, 54 .experiment-workspace b::after { 55 position: absolute; 56 width: var(--cube-size); 57 height: var(--cube-size); 58 content: ''; 59 transform-style: preserve-3d; 60 } 61 62 .experiment-workspace b { 63 background: #e05038; 64 transform: translateZ(var(--cube-size)); 65 } 66 67 .experiment-workspace b::before { 68 background: #f2cbbc; 69 } 70 71 [data-face-y="front"] b::before { 72 transform: rotateX(90deg); 73 transform-origin: bottom; 74 } 75 76 [data-face-y="back"] b::before { 77 transform: rotateX(-90deg); 78 transform-origin: top; 79 } 80 81 [data-face-y="none"] b::before { 82 content: none; 83 } 84 85 .experiment-workspace b::after { 86 background: #334431; 87 } 88 89 [data-face-x="right"] b::after { 90 transform: rotateY(-90deg); 91 transform-origin: right; 92 } 93 94 [data-face-x="left"] b::after { 95 transform: rotateY(90deg); 96 transform-origin: left; 97 } 98 99 [data-face-x="none"] b::after { 100 content: none; 101 } 102 103 .experiment-workspace { 104 display: flex; 105 align-items: center; 106 justify-content: center; 107 background: #e6af4b; 108 height: 400px; 109 } 110 111 .experiment-workspace .rangeZ { 112 position: absolute; 113 top: 30px; 114 } 115 116 @keyframes full-rotation { 117 from { transform: rotateX(70deg) rotateZ(0deg); } 118 to { transform: rotateX(70deg) rotateZ(359deg); } 119 } 120 121 .vertical-in-mobile-horizontal-in-desktop-container { 122 height: 800px; 123 } 124 125 .vertical-in-mobile-horizontal-in-desktop-content { 126 display: flex; 127 flex-direction: column; 128 } 129 130 @media (min-width: 48rem) { 131 .vertical-in-mobile-horizontal-in-desktop-container { 132 height: 400px; 133 } 134 135 .vertical-in-mobile-horizontal-in-desktop-content { 136 flex-direction: row; 137 width: 100%; 138 } 139 } 140 </style> 141 142
142<script> 143 function calcFaces(z) { 144 // Default faces to none in case Z = 0, 180, etc. 145 let fX = 'none'; 146 let fY = 'none'; 147 if (z > 0) { 148 if (z < 180) { 149 fX = 'right'; 150 if (z < 90) { 151 fY = 'front'; 152 } else if (z > 90) { 153 fY = 'back'; 154 } 155 } else if (z > 180) { 156 fX = 'left'; 157 if (z < 270) { 158 fY = 'back'; 159 } else if (z > 270) { 160 fY = 'front'; 161 } 162 } else { 163 fY = 'back'; 164 } 165 } else if (z < 0) { 166 if (z > -180) { 167 fX = 'left'; 168 if (z > -90) { 169 fY = 'front'; 170 } else if (z < -90) { 171 fY = 'back'; 172 } 173 } else if (z < -180) { 174 fX = 'right'; 175 if (z > -270) { 176 fY = 'back'; 177 } else if (z < -270) { 178 fY = 'front'; 179 } 180 } 181 } else { 182 fY = 'front'; 183 } 184 return [fX, fY]; 185 } 186 </script>
186 187</head> 188 189<body> 190 <header class="header" role="banner"> 191 <a class="header__logo pixelated" href="/">Leandro's Web Site</a> 192 </header> 193 194 <header class="entry-head"> 195 <img class="entry-head__image pixelated" src="/blog/single-element-cube/cube-icon.png" itemprop="image" alt="Cube icon" width="54" height="60"> 196 <h1 class="entry-head__headline" itemprop="headline">Single HTML Element Cube with 6 faces</h1> 197 <p class="entry-head__details"><time itemprop="dateCreated" datetime="2019-02-20">February 20, 2019</time> by <a href="/" itemprop="author">Leandro Linares</a></p> 198 <meta itemprop="description" content="No CSS was harmed in the making of this blog post."> 199 </header> 200 201 <section class="entry-body" itemprop="articleBody"> 202 <p>To learn more how CSS works in depth, <strong>I've always explored different ways to use it beyond its original purpose</strong>. For example, I created a <a href="https://lean8086.github.io/casio-calculator" target="_blank" rel="noopener">pure-CSS calculator</a> that actually works.</p> 203 204 <p>People make amazing things by pushing CSS beyond its limits, like single-element drawings in <a href="https://a.singlediv.com/" target="_blank" rel="noopener">A Single Div</a> or oil paintings like <a href="https://diana-adrianne.com/purecss-francine/" target="_blank" rel="noopener">Pure CSS Francine</a>:</p> 205 206 <img src="/blog/single-element-cube/pure-css-francine.jpg" alt="An old paint of a lady made entirely in CSS"/> 207 208 <p>I've made experiments where I have multiple objects generated from one single HTML element, but this time, I wanted to see if I could render a 3D cube from one DOM element.</p> 209 210 <p>Here's the finished experiment:</p> 211 212 <section class="experiment-workspace full-example-workspace"> 213 <input type="range" class="rangeZ" min="0" max="360" value="45"/> 214 215 <div class="context" data-face-x="right" data-face-y="front"> 216 <b></b> 217 </div> 218 219
219<script> 220 document.querySelector('.full-example-workspace .rangeZ').addEventListener('input', function () { 221 const context = document.querySelector('.full-example-workspace .context'); 222 context.style.transform = `rotateX(70deg) rotateZ(${this.value}deg)`; 223 const [faceX, faceY] = calcFaces(this.value); 224 context.setAttribute('data-face-x', faceX); 225 context.setAttribute('data-face-y', faceY); 226 }); 227 </script>
227 228 </section> 229 230 <h2>Definition of a cube</h2> 231 232 <p>If you look at any box, <strong>you can never ever see more than 3 faces at the same time</strong>.</p> 233 234 <img src="/blog/single-element-cube/open-ai-robot-hand.gif" alt="Robot hand swapping a cube"> 235 236 <p>Not only that. <strong>There are some situations depending on the perspective of the observer in which only 2 faces can be seen.</strong> There are even cases in which the observer can only see one face if they're positioned directly at the front of the box.</p> 237 238 <img src="/blog/single-element-cube/3-points-of-view.png" alt="3 cubes. The first one showing 3 faces, second one with 2, and a last one with 1" class="pixelated" width="345" height="105"/> 239 240 <p>This means that <strong>opposite sides of a cube will virtually never exist at the same time for the observer.</strong></p> 241 242 <h2>Building a cube</h2> 243 244 <p>With up to 3 objects to render, I could use CSS pseudo-elements along with the original element to create one face per axis (x,y,z). Then it was a matter of mirror them as the observer's perspective changes.</p> 245 246 <p>For this example, I used a <code><b></code> element (for "box") that serves as the container for all the cube faces.</p> 247 248 <pre><code><b></b></code></pre> 249 250 <p>I used an element with a short name and kept it clean without class names or attributes because <strong>I wanted it to have the smallest footprint as possible</strong>. That way if I made a composition with thousands of elements I could concatenate and render them as a whole string as fast and small as possible.</p> 251 252 <h2>Building a scene</h2> 253 254 <p>To manipulate the entire composition and not individual cubes, such as changes in <strong>rotation and scaling</strong>, I defined a wrapper element. This element was useful for triggering the swap on all the cubes faces as the perspective changed in the composition.</p> 255 256 <pre><code><section class="scene"> 257 <b></b> 258</section></code></pre> 259 260 <p>To make it easier to work with, I setup the entire scene element from the beginning. I chose an isometric projection to work with the 3 sides at the same time.</p> 261 262 <p>
262In an <a href="https://en.wikipedia.org/wiki/Isometric_projection" target="_blank" rel="noopener">isometric projection</a> the 3 coordinate axes appear equally foreshortened:</p> 263 264 <img src="/blog/single-element-cube/cube-icon.png" alt="Example of an isometric cube" class="pixelated" width="162" height="180"> 265 266 <pre><code>.scene { 267 transform-style: preserve-3d; 268 transform: rotateX(70deg) rotateZ(45deg); 269}</code></pre> 270 271 <p>I set a variable with the size of the cube and, only for this example, the scene had the same size as one cube:</p> 272 273 <pre><code>:root { 274 --cube-size: 200px; 275} 276 277.scene { 278 width: var(--cube-size); 279 height: var(--cube-size); 280}</code></pre> 281 282 <h2>Creating the faces</h2> 283 284 <p>All the elements and pseudo-elements had the same base properties such as <strong>size, position and 3D preservation</strong>. I defined all of those together:</p> 285 286 <pre><code>b, 287b::before, 288b::after { 289 content: ''; 290 transform-style: preserve-3d; 291 width: var(--cube-size); 292 height: var(--cube-size); 293 position: absolute; 294}</code></pre> 295 296 <p>Then I positioned the first face in the Z-axis to start giving form to the cube container. As I wanted to have an isometric view, I needed to show the top face of the cube. I positioned the top face as if the cube was resting in the top of the scene element:</p> 297 298 <pre><code>b { 299 background: red; 300 transform: translateZ(var(--cube-size)); 301}</code></pre> 302 303 <section class="experiment-workspace top-face-workspace"> 304 <style> 305 .top-face-workspace b::before, 306 .top-face-workspace b::after { 307 background-color: transparent; 308 border: 1px dashed chocolate; 309 } 310 </style> 311 312 <div class="context" data-face-x="right" data-face-y="front"> 313 <b></b> 314 </div> 315 </section> 316 317 <p>By having the scene container and the first face defined, I positioned the other faces relative to it:</p> 318 319 <p><strong>Face in the X-axis:</strong></p> 320 <pre><code>b::before { 321 background: grey; 322 transform: rotateX(90deg); 323 transform-origin: bottom; 324}</code></pre> 325 326 <p><strong>Face in the Z-axis:</strong></p> 327 <pre><code>b::after { 328 background: white; 329 transform: rotateY(-90deg); 330 transform-origin: right; 331}</code></pre> 332 333 <section class="experiment-workspace x-z-faces-workspace"> 334 <style> 335 @keyframes create-x-face { 336 from { transform: rotateX(.1deg); } 337 25% { transform: rotateX(90deg); } 338 50% { transform: rotateX(90deg); } 339 75% { transform: rotateX(.1deg); } 340 } 341 342 .x-z-faces-workspace b::before { 343 transform: rotateX(.1deg); 344 animation: create-x-face 4s ease-in-out infinite; 345 } 346 347 @keyframes create-z-face { 348 from { transform: rotateY(-.1deg); } 349 25% { transform: rotateY(-90deg); } 350 50% { transform: rotateY(-90deg); } 351 75% { transform: rotateY(-.1deg); } 352 } 353 354 .x-z-faces-workspace b::after { 355 transform: rotateY(-.1deg); 356 animation: create-z-face 4s ease-in-out .9s infinite; 357 } 358 </style> 359 360 <div class="context" data-face-x="right" data-face-y="front"> 361 <b></b> 362 </div> 363 </section> 364 365 <p>Here are the 3 faces properly positioned, from an isometric point of view, rotating on the Z axis:</p> 366 367 <section class="experiment-workspace three-faces-workspace"> 368 <style> 369 .three-faces-workspace .context { 370 animation: full-rotation 3s linear infinite; 371 } 372 </style> 373 374 <div class="context" data-face-x="right" data-face-y="front"> 375 <b></b> 376 </div> 377 </section> 378 379 <p>The isometric example looked great so far, but when the point of view changes in the example above, you can see that the back part of the cube is still missing.</p> 380 381 <h2>Swapping sides</h2> 382 383 <p>As I mentioned at the beginning, by definition, <strong>two opposite faces can never coexist.</strong> So, I needed to place the faces in the opposite side whenever the face is out of the observer's view:</p> 384 385 <img src="/blog/single-element-cube/face-swapping.png" alt="Examples of a cube that swaps its faces on every axis" class="pixelated"/> 386 387 <p>By adding a range-type input to the example I could <strong>determine where the observer is placed respect to the scene and therefore which faces I needed to show.</strong></p> 388 389 <p>To make it simple, I divided the check for the observer's position to 90 degree chunks:</p> 390 391 <!-- <p>For now I limit this example to rotate horizontally (Z axis) having front/back for Y-axis and left/right for X-axis as options.
391</p> --> 392 393 <pre><code>function calcFaces(z) { 394 let fX; 395 let fY; 396 397 if (z > 0) { 398 if (z < 180) { 399 fX = 'right'; 400 if (z < 90) { 401 fY = 'front'; 402 } else if (z > 90) { 403 fY = 'back'; 404 } 405 } else if (z > 180) { 406 fX = 'left'; 407 if (z < 270) { 408 fY = 'back'; 409 } else if (z > 270) { 410 fY = 'front'; 411 } 412 } else { 413 fY = 'back'; 414 } 415 } else if (z < 0) { 416 if (z > -180) { 417 fX = 'left'; 418 if (z > -90) { 419 fY = 'front'; 420 } else if (z < -90) { 421 fY = 'back'; 422 } 423 } else if (z < -180) { 424 fX = 'right'; 425 if (z > -270) { 426 fY = 'back'; 427 } else if (z < -270) { 428 fY = 'front'; 429 } 430 } 431 } else { 432 fY = 'front'; 433 } 434 return [fX, fY]; 435}</code></pre> 436 437 <p>I'm sure this could be improved with some algorithm, but the whole point of making this is only to prove if I could build a cube with only 1 HTML element.</p> 438 439 <p>In order to swap the faces of <code>n</code> amount of cubes inside the scene, I used and updated the attributes <code>data-face-x</code>, <code>data-face-y</code>, and <code>data-face-z</code>. That way I could update an attribute directly into the HTML element and let CSS pick it up.</p> 440 441 <pre><code>const scene = document.querySelector('.scene'); 442const rangeZ = document.querySelector('.rangeZ'); 443 444rangeZ.addEventListener('input', function () { 445 scene.style.transform = `rotateZ(${this.value}deg) rotateX(70deg)`; 446 447 const [ faceX, faceY ] = calcFaces(this.value); 448 449 scene.setAttribute('data-face-x', faceX); 450 scene.setAttribute('data-face-y', faceY); 451});</code></pre> 452 453 <p>Then I wrote all the possible combinations regarding the point of view, splitting responsibilities from the code I had before. Here is an example for the Y axis:</p> 454 455 <pre><code>b::before { 456 background: grey; 457} 458 459[data-face-y="front"] b::before { 460 transform: rotateX(90deg); 461 transform-origin: bottom; 462} 463 464[data-face-y="back"] b::before { 465 transform: rotateX(-90deg); 466 transform-origin: top; 467}</code></pre> 468 469 <p>The face on the Y axis is only shown when it's on the observer's view:</p> 470 471 <section class="experiment-workspace y-face-workspace vertical-in-mobile-horizontal-in-desktop-container"> 472 <style> 473 .y-face-workspace .y-face-context b, 474 .y-face-workspace .y-face-context b::before { 475 background-color: transparent; 476 } 477 </style> 478 479 <input type="range" class="rangeZ" min="0" max="360" value="45"/> 480 481 <div class="vertical-in-mobile-horizontal-in-desktop-content"> 482 <div class="context y-face-context" data-face-x="right" data-face-y="front"> 483 <b></b> 484 </div> 485 486 <div class="context full-context" data-face-x="right" data-face-y="front"> 487 <b></b> 488 </div> 489 </div> 490 491
491<script> 492 document.querySelector('.y-face-workspace .rangeZ').addEventListener('input', function () { 493 const yContext = document.querySelector('.y-face-workspace .y-face-context'); 494 const fullContext = document.querySelector('.y-face-workspace .full-context'); 495 yContext.style.transform = fullContext.style.transform = `rotateX(70deg) rotateZ(${this.value}deg)`; 496 const [faceX, faceY] = calcFaces(this.value); 497 yContext.setAttribute('data-face-x', faceX); 498 yContext.setAttribute('data-face-y', faceY); 499 fullContext.setAttribute('data-face-x', faceX); 500 fullContext.setAttribute('data-face-y', faceY); 501 }); 502 </script>
502 503 </section> 504 505 506 <h2>Optimizations</h2> 507 508 <p>Now <strong>imagine having dozens or maybe thousands of these CSS cubes at the same time to make a composition</strong>. It sets the GPU on fire ð¥. Check it out by yourself in this other experiment I made: <a href="/dev/css-voxels-chicken/">Crossy Road Chicken in CSS Voxels</a>.</p> 509 510 <p><a href="/dev/css-voxels-chicken/"><img src="/blog/single-element-cube/voxels-chicken.png" alt=""/></a></p> 511 512 <p>There are some things I can do to make it easier for the browser, though. Let's dive into them.</p> 513 514 <h3>Hidden elements</h3> 515 516 <p>As I mentioned before, there are cases in which some faces can be hidden to the observer. In order to improve performance, I can temporarily destroy the pseudo-elements using <code>content: none</code> to reduce the amount of elements the browser needs to render.</p> 517 518 <pre><code>function calcFaces(z) { 519 // Define default faces as "none" in case Z = 0, 180, etc. 520 let fX = 'none'; 521 let fY = 'none'; 522 523 // ... 524 525 return [fX, fY]; 526}</code></pre> 527 528 <pre><code>[data-face-y="none"] b::before { 529 content: none; 530} 531 532[data-face-x="none"] b::after { 533 content: none; 534}</code></pre> 535 536 <h3>Backface visibility</h3> 537 538 <p>Going one step further, the observer will only be able to <strong>see only one side of the faces</strong>. There is no need to display both sides of a face at all times. <strong>I can get rid of the back faces of the elements and pseudo-elements</strong>. In order to tell the browser not to render the backfaces I use <code>backface-visibility: hidden</code>.</p> 539 540 <p>Here you can see the element rendering both faces at all times (first fig.) and the element hidding the back face (second fig.):</p> 541 542 <section class="experiment-workspace backface-visibility-workspace vertical-in-mobile-horizontal-in-desktop-container"> 543 <style> 544 .backface-visibility-workspace .visible-context, 545 .backface-visibility-workspace .hidden-context { 546 animation: full-rotation 3s linear infinite; 547 } 548 549 .backface-visibility-workspace b, 550 .backface-visibility-workspace b::before { 551 background-color: transparent; 552 } 553 554 .backface-visibility-workspace .hidden-context b::after { 555 backface-visibility: hidden; 556 } 557 </style> 558 559 <div class="vertical-in-mobile-horizontal-in-desktop-content"> 560 <div class="context visible-context" data-face-x="right" data-face-y="front"> 561 <b></b> 562 </div> 563 564 <div class="context hidden-context" data-face-x="right" data-face-y="front"> 565 <b></b> 566 </div> 567 </div> 568 </section> 569 570 571 <h2>Composition</h2> 572 573 <p>The next step to keep stressing this experiment is to put many of these voxels together. The easiest way to do it is by <strong>adding more elements on the X and Y axes</strong>.</p> 574 575 <img src="/thumbnails/css-voxel-composition.png" alt="/thumbnails/css-voxel-composition.png"> 576 577 <p>By defining the scene as a flexbox container and giving it a size, I can arrange all the elements inside.</p> 578 579 <pre><code>.scene { 580 display: flex; 581 flex-wrap: wrap; 582}</code></pre> 583 584 <p>To create a map of every positioned cube I use the same approach as the <code>imageData</code> property of the HTML canvas. It's <strong>an array containing the data of the canvas element in the RGB order, with integer values between <code>0</code> and <code>255</code></strong>.</p> 585 586 <p>
586In this case I can't implement the alpha channel because the transparency can't live together with swapping faces.</p> 587 588 <h3>Empty elements</h3> 589 590 <p>To make a composition and to fill every empty space between cubes, I introduced an "empty" element. <strong>In case of not needing a cube I can avoid to have the whole definition of a 6-faces elements and pseudo-elements and define the basics to only fill the void.</strong></p> 591 592 <pre><code><b></b> 593<em></em> 594<em></em> 595<b></b></code></pre> 596 597 <img src="/blog/single-element-cube/empty-elements.png" alt="Example of two empty elements between two boxes" class="pixelated"> 598 599 <h2>Climbing the Z-axis</h2> 600 601 <p>By repeating all the previous steps but moving the elements in another matrix in the Z-axis, I can make entire figures: <a href="/dev/css-voxels-chicken/">Crossy Road Chicken in CSS Voxels</a>.</p> 602 603 <p><a href="/dev/css-voxels-chicken/"><img src="/blog/single-element-cube/voxels-chicken.png" alt=""/></a></p> 604 605 <!-- <h2>Voxel editor in CSS</h2> 606 607 <p>I've put all this together using <a href="https://github.com/developit/htm" target="_blank" rel="noopener">HTM</a> and browser modules to make a brief and lightweight app to edit CSS voxel compositions.</p> 608 609 (Screenshot con link) 610 <p><a href="https://voxeland.now.sh/" target="_blank" rel="noopener">Visit Voxeland</a></p> 611 612 <p>The code of this tool is <a href="" target="_blank" rel="noopener">open</a> and every feedback is always welcome.</p> 613 614 <p>Cheers!</p> --> 615 616 <h2>Conclusion</h2> 617 618 <p>I don't see many use cases where I'd apply everything I've detailed here simultaneously, but I've learned how to use each technique individually and can now apply them whenever needed. So it was worth it in the end.</p> 619 </section> 620 621 <footer class="footer" role="contentinfo"><a href="/">Leandro Linares</a></footer> 622 623
623<script async src="https://static.codepen.io/assets/embed/ei.js"></script>
623 624</body> 625</html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.