PageSourceSearch

https://leandrolinares.com/blog/single-element-cube/

html leandrolinares.com collected 2026-10-03 23:19:50 UTC 23,699 bytes, 625 lines download raw bytes

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>&lt;b&gt;</code> element (for "box") that serves as the container for all the cube faces.</p>
247
248    <pre><code>&lt;b&gt;&lt;/b&gt;</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>&lt;section class="scene"&gt;
257  &lt;b&gt;&lt;/b&gt;
258&lt;/section&gt;</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>&lt;b>&lt;/b&gt;
593&lt;em&gt;&lt;/em&gt;
594&lt;em&gt;&lt;/em&gt;
595&lt;b&gt;&lt;/b&gt;</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.