javascripthtmlcssfrontend

Use CSS to create a 3D cube whose faces cannot be clicked when rotated to certain angles


I'm trying to use CSS to create a simple 3D cube that can be rotated by dragging, or select its each faces by clicking them. Here is the code.

const DEFAULT_ROTATION = [-27, -36];
let position = [0, 0];
let rotation = DEFAULT_ROTATION;
let isMoved = false;

const container = document.querySelector(".container");
const rotate = () => {
  container.style.setProperty("--rotate-x", rotation[0] + "deg");
  container.style.setProperty("--rotate-y", rotation[1] + "deg");
};
const reset = () => {
  position = [0, 0];
  rotation = DEFAULT_ROTATION;
  rotate();
};

const cube = document.querySelector(".cube");
cube.addEventListener("pointerdown", e => {
  position = [e.clientX, e.clientY];
  cube.setPointerCapture(e.pointerId);
});
cube.addEventListener("pointermove", e => {
  if (!cube.hasPointerCapture(e.pointerId)) return;
  const rx = floorMod(rotation[0], 360),
    ryDir = rx > 90 && rx < 270 ? 1 : -1;
  rotation = [
    rotation[0] - (Math.round(e.clientY) - position[1]),
    rotation[1] - ryDir * (Math.round(e.clientX) - position[0]) % 360,
  ];
  rotate();
  const newPosition = [Math.round(e.clientX), Math.round(e.clientY)];
  if (!isMoved && Math.hypot(position[0] - newPosition[0], position[1] - newPosition[1]) >= 1) isMoved = true;
  position = newPosition;
});
cube.addEventListener("pointerup", e => {
  position = [0, 0];
  cube.releasePointerCapture(e.pointerId);
  const target = document.elementFromPoint(e.pageX, e.pageY);
  const face = target?.closest(".face");
  if (e.button === 1 || e.button === 2) { if (!isMoved) reset(); } 
  else if (!isMoved && face instanceof HTMLElement) face.click();
  isMoved = false;
});

const faces = document.querySelectorAll(".face");
faces.forEach(face => face.addEventListener("click", e => {
  faces.forEach(face => face.classList.remove("selected"));
  face.classList.add("selected");
}));

function floorMod(x, y) {
  let result = x % y;
  if (result !== 0 && x < 0 !== y < 0)
    result += y;
  return result;
}
.container {
  --side-length: 200px;
  width: var(--side-length);
  height: var(--side-length);
  position: relative;
  transform: rotateX(var(--rotate-x, -27deg)) rotateY(var(--rotate-y, -36deg));
  transform-style: preserve-3d;
  user-select: none;
  transition: all cubic-bezier(0, 0, 0, 1) 250ms;
}

.face {
  width: var(--side-length);
  height: var(--side-length);
  position: absolute;
  align-content: center;
  overflow: clip;
  text-align: center;
  color: black;
  background-color: #ffffffb3;
  border: 2px solid #0000000f;
  border-radius: calc(8 / 200 * var(--side-length));
  pointer-events: auto;
  transition: none;

  &.front {
    transform: translateZ(calc(var(--side-length) / 2));
  }

  &.back {
    transform: translateZ(calc(var(--side-length) / -2)) rotateY(180deg);
  }

  &.left {
    transform: translateX(calc(var(--side-length) / -2)) rotateY(-90deg);
  }

  &.right {
    transform: translateX(calc(var(--side-length) / 2)) rotateY(90deg);
  }

  &.top {
    transform: translateY(calc(var(--side-length) / -2)) rotateX(90deg);
  }

  &.bottom {
    transform: translateY(calc(var(--side-length) / 2)) rotateX(-90deg);
  }

  &.selected {
    color: white;
    background-color: #005fb8;

    &:hover {
      background-color: #005fb8e0;
    }

    &:active {
      background-color: #005fb8c0;
    }
  }

  &:not(.selected) {
    &:hover {
      color: #005fb8;
    }

    &:active {
      color: #005fb880;
    }
  }
}

.cube {
  display: grid;
  place-items: center;
  width: 350px;
  height: 350px;
  perspective: 750px;
  cursor: grab;

  &:active {
    cursor: grabbing;
  }
}

body {
  display: grid;
  place-items: center;
  height: 100vh;
  margin: 0;
}

* {
  box-sizing: border-box;
}
<div class="cube">
  <div class="container">
    <div class="front face">Front</div>
    <div class="back face">Back</div>
    <div class="left face">Left</div>
    <div class="right face">Right</div>
    <div class="top face">Top</div>
    <div class="bottom face">Bottom</div>
  </div>
</div>

3D cube

In general, it works fine. However, if a face (including top, bottom, left, right, but excluding front and back) is facing the screen, it cannot be clicked. For example:

The top face is facing the screen

This face cannot be clicked, and only one of the random side faces can be clicked.

Even if I open DevTools to inspect the element (Ctrl + Shift + C), it still won't be selected.

I tried the method here, it reduces the angle where it can't be clicked. However, it still doesn't work when rotated to exactly 90 degrees.

Only for Chromium issues, Firefox works fine.


Solution

  • It seems that if you remove the overflow property from .face, it works properly.

    const DEFAULT_ROTATION = [-27, -36];
    let position = [0, 0];
    let rotation = DEFAULT_ROTATION;
    let isMoved = false;
    
    const container = document.querySelector(".container");
    const rotate = () => {
      container.style.setProperty("--rotate-x", rotation[0] + "deg");
      container.style.setProperty("--rotate-y", rotation[1] + "deg");
    };
    const reset = () => {
      position = [0, 0];
      rotation = DEFAULT_ROTATION;
      rotate();
    };
    
    const cube = document.querySelector(".cube");
    cube.addEventListener("pointerdown", e => {
      position = [e.clientX, e.clientY];
      cube.setPointerCapture(e.pointerId);
    });
    cube.addEventListener("pointermove", e => {
      if (!cube.hasPointerCapture(e.pointerId)) return;
      const rx = floorMod(rotation[0], 360),
        ryDir = rx > 90 && rx < 270 ? 1 : -1;
      rotation = [
        rotation[0] - (Math.round(e.clientY) - position[1]),
        rotation[1] - ryDir * (Math.round(e.clientX) - position[0]) % 360,
      ];
      rotate();
      const newPosition = [Math.round(e.clientX), Math.round(e.clientY)];
      if (!isMoved && Math.hypot(position[0] - newPosition[0], position[1] - newPosition[1]) >= 1) isMoved = true;
      position = newPosition;
    });
    cube.addEventListener("pointerup", e => {
      position = [0, 0];
      cube.releasePointerCapture(e.pointerId);
      const target = document.elementFromPoint(e.pageX, e.pageY);
      const face = target?.closest(".face");
      if (e.button === 1 || e.button === 2) { if (!isMoved) reset(); } 
      else if (!isMoved && face instanceof HTMLElement) face.click();
      isMoved = false;
    });
    
    const faces = document.querySelectorAll(".face");
    faces.forEach(face => face.addEventListener("click", e => {
      faces.forEach(face => face.classList.remove("selected"));
      face.classList.add("selected");
    }));
    
    function floorMod(x, y) {
      let result = x % y;
      if (result !== 0 && x < 0 !== y < 0)
        result += y;
      return result;
    }
    .container {
      --side-length: 200px;
      width: var(--side-length);
      height: var(--side-length);
      position: relative;
      transform: rotateX(var(--rotate-x, -27deg)) rotateY(var(--rotate-y, -36deg));
      transform-style: preserve-3d;
      user-select: none;
      transition: all cubic-bezier(0, 0, 0, 1) 250ms;
    }
    
    .face {
      width: var(--side-length);
      height: var(--side-length);
      position: absolute;
      align-content: center;
      /* overflow: clip; */
      text-align: center;
      color: black;
      background-color: #ffffffb3;
      border: 2px solid #0000000f;
      border-radius: calc(8 / 200 * var(--side-length));
      pointer-events: auto;
      transition: none;
    
      &.front {
        transform: translateZ(calc(var(--side-length) / 2));
      }
    
      &.back {
        transform: translateZ(calc(var(--side-length) / -2)) rotateY(180deg);
      }
    
      &.left {
        transform: translateX(calc(var(--side-length) / -2)) rotateY(-90deg);
      }
    
      &.right {
        transform: translateX(calc(var(--side-length) / 2)) rotateY(90deg);
      }
    
      &.top {
        transform: translateY(calc(var(--side-length) / -2)) rotateX(90deg);
      }
    
      &.bottom {
        transform: translateY(calc(var(--side-length) / 2)) rotateX(-90deg);
      }
    
      &.selected {
        color: white;
        background-color: #005fb8;
    
        &:hover {
          background-color: #005fb8e0;
        }
    
        &:active {
          background-color: #005fb8c0;
        }
      }
    
      &:not(.selected) {
        &:hover {
          color: #005fb8;
        }
    
        &:active {
          color: #005fb880;
        }
      }
    }
    
    .cube {
      display: grid;
      place-items: center;
      width: 350px;
      height: 350px;
      perspective: 750px;
      cursor: grab;
    
      &:active {
        cursor: grabbing;
      }
    }
    
    body {
      display: grid;
      place-items: center;
      height: 100vh;
      margin: 0;
    }
    
    * {
      box-sizing: border-box;
    }
    <div class="cube">
      <div class="container">
        <div class="front face">Front</div>
        <div class="back face">Back</div>
        <div class="left face">Left</div>
        <div class="right face">Right</div>
        <div class="top face">Top</div>
        <div class="bottom face">Bottom</div>
      </div>
    </div>