Pure CSS Buttons & forms #hover #ui

Rolling button in pure CSS

The button is a bar with two faces; hover rolls the second one into view.

  • 62 lines of CSS
  • 8 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

How it works

  1. The hovered element must not be the one that moves. The <button> is a static hit target; the bar inside it rolls and has pointer-events: none. If the button itself rotated, its hit area would turn away from the pointer mid-roll, :hover would drop, and it would snap back and forth.
  2. The bar has two faces: the front, and the bottom (rotateX(-90deg)).
  3. Both are pushed out by half the bar’s height, so together they form two sides of a square prism.
  4. Hover rotates the whole bar rotateX(90deg), rolling the bottom face up to the front.
  5. The extra translateZ(-h/2) on the bar keeps the front face at z = 0, so the text stays the same size and sharp.

Key techniques

  • static hit target, moving child
  • two faces 90° apart
  • translateZ(-h/2) keeps it in place

Copy-paste code

HTML 8 lines

<div class="scene">
  <button class="roll" type="button">
    <span class="bar">
      <span>Hover me</span>
      <span>Let's go →</span>
    </span>
  </button>
</div>

CSS 62 lines

.scene {
  perspective: 600px;
}

/* the button is the hit target and never moves */
.roll {
  --h: 56px;
  display: block;
  width: 220px;
  height: var(--h);
  padding: 0;
  border: 0;
  background: none;
  color: #fff;
  font: 800 1.1rem system-ui;
  cursor: pointer;
  transform-style: preserve-3d;
}

/* only the bar inside it rolls */
.bar {
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  pointer-events: none;
  transform-style: preserve-3d;
  transform: translateZ(calc(var(--h) / -2));
  transition: transform 0.45s cubic-bezier(0.3, 1.3, 0.5, 1);
}

.roll:hover .bar,
.roll:focus-visible .bar {
  transform: translateZ(calc(var(--h) / -2)) rotateX(90deg);
}

.bar span {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  border-radius: 6px;
  backface-visibility: hidden;
}

.bar span:first-child {
  background: #8b6cff;
  transform: translateZ(calc(var(--h) / 2));
}

/* hidden at rest, otherwise its edge shows as a thin line under the button */
.bar span:last-child {
  background: #ff4d9d;
  opacity: 0;
  transform: rotateX(-90deg) translateZ(calc(var(--h) / 2));
  transition: opacity 0.1s;
}

.roll:hover .bar span:last-child,
.roll:focus-visible .bar span:last-child {
  opacity: 1;
}

Sass source 65 lines

$h: 50px;

// The <button> is the hit target and NEVER moves. Only the bar inside it rolls.
// If the rolling element were also the hovered element, its hit area would rotate away from
// the pointer mid-animation, :hover would drop, and the button would snap back and forth.
.d-rollbutton {
  display: block;
  width: 190px;
  height: $h;
  padding: 0;
  border: 0;
  background: none;
  color: #fff;
  font-size: 16px;
  font-weight: 800;
  cursor: pointer;
  transform-style: preserve-3d;

  &__bar {
    position: relative;
    display: block;
    width: 100%;
    height: 100%;
    pointer-events: none; // the pointer only ever "sees" the static button
    transform-style: preserve-3d;
    // Pull the bar back by half its depth so the front face sits exactly at z = 0.
    transform: translateZ($h * -0.5);
    transition: transform 0.45s cubic-bezier(0.3, 1.3, 0.5, 1);

    span {
      position: absolute;
      inset: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 6px;
      border-radius: 6px;
      backface-visibility: hidden;
    }

    // front face
    span:first-child {
      background: var(--accent);
      transform: translateZ($h * 0.5);
    }

    // bottom face: rolls up to the front on hover.
    // Hidden at rest, otherwise its edge shows as a thin line under the button.
    span:last-child {
      background: var(--hot);
      opacity: 0;
      transform: rotateX(-90deg) translateZ($h * 0.5);
      transition: opacity 0.1s;
    }
  }

  &:hover &__bar,
  &:focus-visible &__bar {
    transform: translateZ($h * -0.5) rotateX(90deg);

    span:last-child {
      opacity: 1;
    }
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.