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.

  • 75 lines of CSS
  • 8 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

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.
  6. Every length is a multiple of one base unit, --u, tied to the canvas, so the bar, its type and the depth it rolls through are the same share of a gallery card, the editor and a recording canvas. The numbers below are those units.
  7. The prism is square in section, so halfway through the roll it stands on a corner and sweeps √2 × h tall. That corner, not the resting bar, is what has to fit the band — which is why the bar rests at about 50vmin and touches 70vmin only in passing.

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 75 lines

.scene {
  /* one base unit: every length below is a multiple of it, so the bar is the same share of a
     card, the editor, a full screen and a recording canvas */
  --u: 0.32vmin;
  perspective: calc(600 * var(--u));
}

/* the button is the hit target and never moves */
.roll {
  --h: calc(150 * var(--u));
  display: block;
  width: calc(230 * var(--u));
  height: var(--h);
  padding: 0;
  border: 0;
  background: none;
  color: #fff;
  font: 800 calc(24 * var(--u)) Inter, 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: calc(12 * var(--u));
  backface-visibility: hidden;
}

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

/* the arrow after "Let's go" is drawn, not typed: → is in neither font an export carries, so
   every device, and the export service, drew one of its own. Side by side with the words */
.bar span:last-child {
  grid-auto-flow: column;
  justify-content: center;
  column-gap: 0.4em;
}
.bar span:last-child::after {
  content: '';
  width: 0.85em;
  height: 0.7em;
  background: currentColor;
  clip-path: polygon(0 40%, 58% 40%, 58% 8%, 100% 50%, 58% 92%, 58% 60%, 0 60%);
}

/* At rest it is the bottom of the prism, facing down and away from you, so backface-visibility
   already hides it. It is never faded: a fade quicker than the roll turned it dark in one step
   as the pointer left, while it was still rolling away in plain sight. */
.bar span:last-child {
  background: #ff4d9d;
  transform: rotateX(-90deg) translateZ(calc(var(--h) / 2));
}
Standalone snippet · plain CSS, no build step, no dependencies.