Pure CSS Data & tools #loop #sass-loop

3D bar chart in pure CSS

Each bar is a real cuboid — front, side and top face — growing on a staggered delay.

  • 68 lines of CSS
  • 10 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed
GitHub

How it works

  1. Each bar is a cuboid made of only the three faces you can actually see: front, right side, top.
  2. Do not animate height — that runs layout on every frame. The bar’s box stays full size; the walls are squashed with scaleY from transform-origin: bottom.
  3. The lid is a fixed square laid flat with rotateX(90deg); it slides down with translateY by exactly the height the walls lost, so it stays sitting on top.
  4. Shade the three faces differently (light top, mid front, dark side) — that fake lighting does most of the work.

Key techniques

  • preserve-3d inside flexbox
  • scaleY walls + translateY lid (no layout)
  • animation-delay stagger

Copy-paste code

HTML 10 lines

<div class="scene">
  <div class="chart">
    <!-- --hn = full height in px, as a plain number -->
    <div class="bar" style="--hn:70;  --hue:262; --delay:0s"><i></i><i></i><i></i></div>
    <div class="bar" style="--hn:110; --hue:285; --delay:-0.35s"><i></i><i></i><i></i></div>
    <div class="bar" style="--hn:50;  --hue:320; --delay:-0.7s"><i></i><i></i><i></i></div>
    <div class="bar" style="--hn:140; --hue:175; --delay:-1.05s"><i></i><i></i><i></i></div>
    <div class="bar" style="--hn:90;  --hue:40;  --delay:-1.4s"><i></i><i></i><i></i></div>
  </div>
</div>

CSS 68 lines

.scene {
  perspective: 900px;
}

.chart {
  --w: 30px;
  display: flex;
  align-items: flex-end;
  gap: 18px;
  height: 150px;
  transform-style: preserve-3d;
  transform: rotateX(-22deg) rotateY(-32deg);
}

.bar {
  --h: calc(var(--hn) * 1px);     /* full height */
  --k: calc(14 / var(--hn));      /* scale of the shortest state: 14px */
  position: relative;
  width: var(--w);
  height: var(--h);               /* the box itself never changes size */
  transform-style: preserve-3d;
}

.bar i {
  position: absolute;
  top: 0;
  left: 0;
  width: var(--w);
  height: 100%;
  transform-origin: bottom center;
  animation: 1.8s ease-in-out var(--delay) infinite alternate;
}

/* front */
.bar i:nth-child(1) {
  background: hsl(var(--hue) 80% 60%);
  transform: translateZ(calc(var(--w) / 2));
  animation-name: grow-front;
}

/* right side */
.bar i:nth-child(2) {
  background: hsl(var(--hue) 70% 42%);
  transform: rotateY(90deg) translateZ(calc(var(--w) / 2));
  animation-name: grow-side;
}

/* lid */
.bar i:nth-child(3) {
  height: var(--w);
  background: hsl(var(--hue) 90% 74%);
  transform-origin: center;
  transform: rotateX(90deg) translateZ(calc(var(--w) / 2));
  animation-name: grow-lid;
}

/* each keyframe gives only the SHORT state; the tall state is the element's own transform */
@keyframes grow-front {
  from { transform: translateZ(calc(var(--w) / 2)) scaleY(var(--k)); }
}

@keyframes grow-side {
  from { transform: rotateY(90deg) translateZ(calc(var(--w) / 2)) scaleY(var(--k)); }
}

@keyframes grow-lid {
  from { transform: translateY(calc(var(--h) - 14px)) rotateX(90deg) translateZ(calc(var(--w) / 2)); }
}

Sass source 86 lines

@use 'sass:list';

$w: 26px;
$min: 14; // shortest bar height in px (unitless so it can be divided by --hn)
$hues: (262, 285, 320, 175, 40);

.d-bars {
  display: flex;
  align-items: flex-end;
  gap: 16px;
  height: 130px;
  transform-style: preserve-3d;
  transform: translateY(10px) rotateX(-22deg) rotateY(-32deg);

  // --hn is the full height as a plain number, set in the markup.
  // The bar's box never changes size: animating `height` would run layout every frame.
  // Instead the walls are squashed with scaleY and the lid slides down with translateY —
  // transforms only, so the whole thing runs on the compositor.
  &__bar {
    --h: calc(var(--hn) * 1px);
    --k: calc(#{$min} / var(--hn)); // scale factor of the shortest state
    position: relative;
    width: $w;
    height: var(--h);
    transform-style: preserve-3d;

    @for $i from 1 through 5 {
      &:nth-child(#{$i}) {
        --hue: #{list.nth($hues, $i)};
        --delay: #{$i * -0.35s};
      }
    }

    i {
      position: absolute;
      top: 0;
      left: 0;
      width: $w;
      height: 100%;
      transform-origin: bottom center;
      animation: 1.8s ease-in-out var(--delay) infinite alternate;
    }

    // front
    i:nth-child(1) {
      background: hsl(var(--hue) 80% 60%);
      transform: translateZ($w * 0.5);
      animation-name: d-bars-front;
    }

    // right side — a little darker
    i:nth-child(2) {
      background: hsl(var(--hue) 70% 42%);
      transform: rotateY(90deg) translateZ($w * 0.5);
      animation-name: d-bars-side;
    }

    // top — a $w × $w square laid flat, lightest
    i:nth-child(3) {
      height: $w;
      background: hsl(var(--hue) 90% 74%);
      transform-origin: center;
      transform: rotateX(90deg) translateZ($w * 0.5);
      animation-name: d-bars-top;
    }
  }
}

// Each keyframe only gives the SHORT state; the tall state is the element's own transform.
@keyframes d-bars-front {
  from {
    transform: translateZ($w * 0.5) scaleY(var(--k));
  }
}

@keyframes d-bars-side {
  from {
    transform: rotateY(90deg) translateZ($w * 0.5) scaleY(var(--k));
  }
}

@keyframes d-bars-top {
  from {
    transform: translateY(calc(var(--h) - #{$min * 1px})) rotateX(90deg) translateZ($w * 0.5);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.