Pure CSS Products & branding #hover #loop #product #device #music #cylinder

Headphones in pure CSS

Over-ear headphones turning on a stand: the band is a ring of tangent strips, each cup a short cylinder laid on its side. Hover or focus and they turn to face you and light up.

  • 219 lines of CSS
  • 58 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

How it works

  1. The band is twelve flat strips. Each one starts at the arc centre, turns to its angle with rotateZ, moves out by the radius with translateY(-58px) and folds flat with rotateX(90deg): together they are the top surface of an arch. Two masked rings close its sides.
  2. A cup is a short cylinder built the easy way, standing up like a can (strips at rotateY(i × 30deg) translateZ(r), a disc on each end), then laid on its side with rotateZ(±90deg) so the top disc faces outward.
  3. Light is computed, not painted by hand: each strip darkens itself with cos() / sin() of its own angle, so the strips facing up are the brightest.
  4. The turn cannot simply be switched off on hover: removing an animation snaps back. Instead two @property values animate: the lap angle --spin and a weight --k. The transform uses --spin × --k, and hover eases --k to 0, which glides from wherever the lap is to the front pose.
  5. The hovered element is a static wrapper and everything that moves has pointer-events: none, so the hover never flickers.

Key techniques

  • arc = strips at rotateZ(a) translateY(−r) rotateX(90deg)
  • cylinder built upright, laid down with rotateZ(90deg)
  • shading from sin() / cos() of the strip angle
  • @property blend: hover eases --k from 1 to 0

Copy-paste code

HTML 58 lines

<div class="scene">
  <div class="headphones" tabindex="0">
   <div class="rig">
    <i class="shadow"></i>
    <div class="band">
      <b class="side"></b><b class="side"></b>
      <i style="--i:0"></i>
      <i style="--i:1"></i>
      <i style="--i:2"></i>
      <i style="--i:3"></i>
      <i style="--i:4"></i>
      <i style="--i:5"></i>
      <i style="--i:6"></i>
      <i style="--i:7"></i>
      <i style="--i:8"></i>
      <i style="--i:9"></i>
      <i style="--i:10"></i>
      <i style="--i:11"></i>
    </div>
    <i class="slider slider-l"></i><i class="slider slider-lf"></i>
    <i class="yoke yoke-l"></i>
    <div class="cup cup-l">
      <i style="--i:0"></i>
      <i style="--i:1"></i>
      <i style="--i:2"></i>
      <i style="--i:3"></i>
      <i style="--i:4"></i>
      <i style="--i:5"></i>
      <i style="--i:6"></i>
      <i style="--i:7"></i>
      <i style="--i:8"></i>
      <i style="--i:9"></i>
      <i style="--i:10"></i>
      <i style="--i:11"></i>
      <b class="outer"><em></em></b>
      <b class="cushion"></b>
    </div>
    <i class="slider slider-r"></i><i class="slider slider-rf"></i>
    <i class="yoke yoke-r"></i>
    <div class="cup cup-r">
      <i style="--i:0"></i>
      <i style="--i:1"></i>
      <i style="--i:2"></i>
      <i style="--i:3"></i>
      <i style="--i:4"></i>
      <i style="--i:5"></i>
      <i style="--i:6"></i>
      <i style="--i:7"></i>
      <i style="--i:8"></i>
      <i style="--i:9"></i>
      <i style="--i:10"></i>
      <i style="--i:11"></i>
      <b class="outer"><em></em></b>
      <b class="cushion"></b>
    </div>
   </div>
  </div>
</div>

CSS 219 lines

/* registered, so they can be animated and transitioned */
@property --spin {
  syntax: '<angle>';
  inherits: false;
  initial-value: 0deg;
}

@property --k {
  syntax: '<number>';
  inherits: false;
  initial-value: 1;
}

.scene {
  perspective: 800px;
}

/* the static hit area */
.headphones {
  --shell: color-mix(in srgb, #8b6cff 34%, #17182a);
  --shell-hi: color-mix(in srgb, #8b6cff 55%, #3a3d5c);
  --leather: color-mix(in srgb, #8b6cff 14%, #1b1c29);
  --leather-hi: color-mix(in srgb, #8b6cff 22%, #3a3c52);
  --stitch: color-mix(in srgb, #8b6cff 80%, #fff);
  --pad: #16171f;
  --metal: #b8bdd2;
  --metal-dark: #5c6280;
  --glow: #2ee6d6;
  display: grid;
  place-items: center;
  width: 216px;
  height: 184px;
  border-radius: 16px;
  cursor: pointer;
  transform-style: preserve-3d;
}

/* 0 × 0: every part is placed around this point */
.rig {
  position: relative;
  width: 0;
  height: 0;
  pointer-events: none;
  transform-style: preserve-3d;
  /* the front pose (-28deg) plus the lap, weighted by --k */
  transform: rotateX(-14deg) rotateY(calc(-28deg + var(--spin) * var(--k)));
  animation: turn 16s linear infinite;
  transition: --k 1.1s cubic-bezier(0.45, 0, 0.2, 1);
}

.headphones:hover .rig,
.headphones:focus-visible .rig {
  --k: 0; /* the lap keeps running, it just stops counting */
}

.shadow {
  position: absolute;
  left: -80px;
  top: 34px;
  width: 160px;
  height: 80px;
  border-radius: 50%;
  background: radial-gradient(closest-side, rgb(0 0 0 / 0.45), transparent);
  transform: rotateX(90deg);
}

/* --- the band: 12 strips over 200deg, radius 58px --- */
.band {
  position: absolute;
  top: -22px;
  transform-style: preserve-3d;
}

.band i {
  --a: calc(-100deg + (var(--i) + 0.5) * 16.667deg);
  position: absolute;
  left: -8.8px;
  top: -11px;
  width: 17.6px; /* one side of the polygon, a hair wider: no seams */
  height: 22px;  /* the band's width, front to back */
  background:
    linear-gradient(rgb(0 0 0 / calc(0.5 - 0.46 * cos(var(--a)))) 0 0), /* shade: none on top */
    repeating-linear-gradient(90deg, var(--stitch) 0 2px, transparent 2px 4px) 0 3px / 100% 1px no-repeat,
    repeating-linear-gradient(90deg, var(--stitch) 0 2px, transparent 2px 4px) 0 18px / 100% 1px no-repeat,
    linear-gradient(var(--leather), var(--leather-hi) 45%, var(--leather));
  transform: rotateZ(var(--a)) translateY(-58px) rotateX(90deg);
}

/* the band's side walls: a ring, masked to the same 200deg */
.side {
  position: absolute;
  left: -58px;
  top: -58px;
  width: 116px;
  height: 116px;
  background: radial-gradient(closest-side, transparent calc(100% - 6px), var(--leather) calc(100% - 5.5px), color-mix(in srgb, var(--leather) 70%, #000) 100%, transparent 100%);
  mask: conic-gradient(from -100deg, #000 0 200deg, transparent 0);
  transform: translateZ(11px);
}

.side + .side {
  transform: translateZ(-11px);
}

/* --- metal: sliders (two crossed planes) and yokes (half rings) --- */
.slider {
  position: absolute;
  top: -18px;
  left: -5px;
  width: 10px;
  height: 20px;
  background: linear-gradient(90deg, var(--metal-dark), var(--metal) 45%, #eef0fa 55%, var(--metal-dark));
}

.slider-l { transform: translateX(-55px) rotateY(90deg); }
.slider-r { transform: translateX(55px) rotateY(90deg); }
.slider-lf { left: -1.5px; width: 3px; transform: translateX(-55px); }
.slider-rf { left: -1.5px; width: 3px; transform: translateX(55px); }

.yoke {
  position: absolute;
  left: -35px;
  top: 0;
  width: 70px;
  height: 70px;
  background: radial-gradient(closest-side, transparent calc(100% - 4px), var(--metal) calc(100% - 3.5px), var(--metal-dark) 100%, transparent 100%);
  mask: conic-gradient(from -90deg, #000 0 180deg, transparent 0);
}

.yoke-l { transform: translateX(-55px) rotateY(90deg); }
.yoke-r { transform: translateX(55px) rotateY(90deg); }

/* --- cups: built standing (axis Y), then laid down; top disc = outside --- */
.cup {
  --dir: 1; /* which way "up" went: flips the light */
  position: absolute;
  top: 35px;
  transform-style: preserve-3d;
  transform: translateX(55px) rotateZ(90deg);
}

.cup-l {
  --dir: -1;
  transform: translateX(-55px) rotateZ(-90deg);
}

/* 12 strips, radius 31px, 26px thick: 17px shell, a seam, 9px cushion */
.cup i {
  position: absolute;
  left: -8.6px;
  top: -13px;
  width: 17.2px;
  height: 26px;
  backface-visibility: hidden;
  background:
    linear-gradient(rgb(0 0 0 / calc(0.3 + 0.28 * var(--dir) * sin(var(--i) * 30deg))) 0 0),
    linear-gradient(var(--shell-hi) 0 2px, var(--shell) 4px 15px, var(--shell-hi) 16.5px, transparent 16.5px),
    linear-gradient(transparent 17px, #2a2c3c 18px, var(--pad) 21px, #0b0c12);
  transform: rotateY(calc(var(--i) * 30deg)) translateZ(31px);
}

/* the seam light: its own layer, so hover only changes opacity */
.cup i::after {
  content: '';
  position: absolute;
  inset: 16px 0 auto;
  height: 1.5px;
  background: var(--glow);
  opacity: 0.25;
  transition: opacity 0.5s;
}

.outer,
.cushion {
  position: absolute;
  left: -31.5px;
  top: -31.5px;
  width: 63px;
  height: 63px;
  border-radius: 50%;
  backface-visibility: hidden;
}

.outer {
  background:
    radial-gradient(circle, var(--metal) 0 2.5px, transparent 3px),
    radial-gradient(circle, transparent 0 7px, var(--metal) 7.5px 8.5px, transparent 9px),
    radial-gradient(circle, transparent 0 83%, var(--metal-dark) 85%, #eef0fa 90%, var(--metal) 94%, var(--metal-dark)),
    radial-gradient(circle at 35% 30%, var(--shell-hi), var(--shell) 60%, color-mix(in srgb, var(--shell) 60%, #000));
  transform: rotateX(90deg) translateZ(13px);
}

.outer em {
  position: absolute;
  inset: 9px;
  border: 2px solid var(--glow);
  border-radius: 50%;
  box-shadow: 0 0 8px var(--glow), inset 0 0 8px var(--glow);
  opacity: 0.2;
  transition: opacity 0.5s;
}

.cushion {
  background: radial-gradient(circle, #06070b 0 52%, #2a2c3c 60%, var(--pad) 72%, #2a2c3c 86%, #0b0c12);
  transform: rotateX(-90deg) translateZ(13px);
}

.headphones:hover .outer em,
.headphones:focus-visible .outer em,
.headphones:hover .cup i::after,
.headphones:focus-visible .cup i::after {
  opacity: 1;
}

/* -180 → 180: blending back to the pose never takes more than half a turn */
@keyframes turn {
  from { --spin: -180deg; }
  to   { --spin: 180deg; }
}

Sass source 285 lines

@use 'sass:math';

// Over-ear headphones: an arched band of flat strips, two sliders, two yokes and two cups. Each
// cup is a short cylinder (12 strips + 2 discs) lying on its side, so it has real thickness.
$rb: 58px; // band radius (outer surface)
$bt: 6px; // band thickness (radial)
$bw: 22px; // band width (front to back)
$arc: 200deg; // how much of a circle the band covers
$nb: 12; // band strips
$step: math.div($arc, $nb);
$seg: 2 * $rb * math.tan(math.div($step, 2)); // one strip = one side of the polygon
$cy: -22px; // arc centre (rig coordinates: 0 0 is the middle of the whole object)
$end: $cy - $rb * math.cos(math.div($arc, 2)); // height where the band ends
$cx: 55px; // cup centre, left / right
$rc: 31px; // cup radius
$ry: $rc + 4px; // yoke radius
$ccy: $end + 12px + $ry; // cup centre height: slider, then yoke, then the cup
$t: 26px; // cup thickness: 17px shell + 9px cushion
$nc: 12; // strips per cup
$cw: 2 * $rc * math.tan(math.div(180deg, $nc));
$turn: 16s;

// Two registered properties, so they can animate: the lap angle, and how much of it is applied.
// Hover takes --d-headphones-k to 0, which blends ANY point of the lap smoothly back to the pose.
@property --d-headphones-spin {
  syntax: '<angle>';
  inherits: false;
  initial-value: 0deg;
}

@property --d-headphones-k {
  syntax: '<number>';
  inherits: false;
  initial-value: 1;
}

// Static hit area: hovering it never moves it.
.d-headphones {
  --shell: color-mix(in srgb, var(--accent) 34%, #17182a);
  --shell-hi: color-mix(in srgb, var(--accent) 55%, #3a3d5c);
  --pad: #16171f;
  --leather: color-mix(in srgb, var(--accent) 14%, #1b1c29);
  --leather-hi: color-mix(in srgb, var(--accent) 22%, #3a3c52);
  --metal: #b8bdd2;
  --metal-dark: #5c6280;
  --glow: var(--accent-2);
  display: grid;
  place-items: center;
  width: 216px;
  height: 184px;
  border-radius: 16px;
  outline-offset: -4px;
  cursor: pointer;
  transform-style: preserve-3d;

  &__rig {
    position: relative;
    width: 0;
    height: 0;
    pointer-events: none;
    transform-style: preserve-3d;
    // The product shot (-28deg) plus the lap, scaled by k. Only one angle feeds the transform.
    transform: rotateX(-14deg) rotateY(calc(-28deg + var(--d-headphones-spin) * var(--d-headphones-k)));
    animation: d-headphones-turn $turn linear infinite;
    transition: --d-headphones-k 1.1s cubic-bezier(0.45, 0, 0.2, 1);
  }

  &__shadow {
    position: absolute;
    left: -80px;
    top: $ccy + $rc + 8px - 40px;
    width: 160px;
    height: 80px;
    border-radius: 50%;
    background: radial-gradient(closest-side, rgb(0 0 0 / 0.45), transparent);
    transform: rotateX(90deg);
  }

  // --- the band ---------------------------------------------------------------------------
  // Strips stand at the arc centre, turn by their angle, move out by the radius and fold flat
  // (rotateX 90deg): each one becomes a tangent slice of the band's top surface.
  &__band {
    position: absolute;
    left: 0;
    top: $cy;
    transform-style: preserve-3d;

    > i {
      --a: calc(#{-0.5 * $arc} + (var(--i) + 0.5) * #{$step});
      position: absolute;
      left: -0.5 * $seg - 0.3px;
      top: -0.5 * $bw;
      width: $seg + 0.6px; // a hair wider: no hairline seams
      height: $bw;
      // light from above: the strip facing up (cos = 1) gets no shade
      background:
        linear-gradient(rgb(0 0 0 / calc(0.5 - 0.46 * cos(var(--a)))) 0 0),
        repeating-linear-gradient(90deg, color-mix(in srgb, var(--accent) 80%, #fff) 0 2px, transparent 2px 4px) 0 3px / 100% 1px no-repeat,
        repeating-linear-gradient(90deg, color-mix(in srgb, var(--accent) 80%, #fff) 0 2px, transparent 2px 4px) 0 #{$bw - 4px} / 100% 1px no-repeat,
        linear-gradient(var(--leather), var(--leather-hi) 45%, var(--leather) 100%);
      transform: rotateZ(var(--a)) translateY(-$rb) rotateX(90deg);
    }
  }

  // The band's two side walls: one ring, masked to the band's arc.
  &__side {
    position: absolute;
    left: -$rb;
    top: -$rb;
    width: 2 * $rb;
    height: 2 * $rb;
    background: radial-gradient(closest-side, transparent calc(100% - #{$bt}), var(--leather) calc(100% - #{$bt} + 0.5px), color-mix(in srgb, var(--leather) 70%, #000) 100%, transparent 100%);
    mask: conic-gradient(from #{-0.5 * $arc}, #000 0 $arc, transparent 0);
    transform: translateZ(0.5 * $bw);

    & + & {
      transform: translateZ(-0.5 * $bw);
    }
  }

  // --- sliders and yokes (metal) ------------------------------------------------------------
  // Two crossed planes make a bar that has width from every angle.
  &__slider {
    position: absolute;
    top: $end - 6px;
    height: 20px;
    background: linear-gradient(90deg, var(--metal-dark), var(--metal) 45%, #eef0fa 55%, var(--metal-dark));

    &--l,
    &--r {
      left: -5px;
      width: 10px;
    }

    &--l {
      transform: translateX(-$cx) rotateY(90deg);
    }

    &--r {
      transform: translateX($cx) rotateY(90deg);
    }

    &--lf,
    &--rf {
      left: -1.5px;
      width: 3px;
    }

    &--lf {
      transform: translateX(-$cx);
    }

    &--rf {
      transform: translateX($cx);
    }
  }

  // The fork around each cup: a half ring standing in the plane through the cup's axis.
  &__yoke {
    position: absolute;
    left: -$ry;
    top: $ccy - $ry;
    width: 2 * $ry;
    height: 2 * $ry;
    background: radial-gradient(closest-side, transparent calc(100% - 4px), var(--metal) calc(100% - 3.5px), var(--metal-dark) 100%, transparent 100%);
    mask: conic-gradient(from -90deg, #000 0 180deg, transparent 0);

    &--l {
      transform: translateX(-$cx) rotateY(90deg);
    }

    &--r {
      transform: translateX($cx) rotateY(90deg);
    }
  }

  // --- the cups -----------------------------------------------------------------------------
  // Built as a can standing up (axis = Y), then laid on its side with rotateZ(±90deg) so the
  // top disc ends up facing outward on both sides.
  &__cup {
    --dir: 1; // which way "up" went: flips the light on the strips
    position: absolute;
    left: 0;
    top: $ccy;
    transform-style: preserve-3d;
    transform: translateX($cx) rotateZ(90deg);

    &--l {
      --dir: -1;
      transform: translateX(-$cx) rotateZ(-90deg);
    }

    // shell (outer 17px), a thin glow seam, cushion (inner 9px)
    > i {
      position: absolute;
      left: -0.5 * $cw - 0.3px;
      top: -0.5 * $t;
      width: $cw + 0.6px;
      height: $t;
      backface-visibility: hidden;
      background:
        linear-gradient(rgb(0 0 0 / calc(0.3 + 0.28 * var(--dir) * sin(var(--i) * #{math.div(360deg, $nc)}))) 0 0),
        linear-gradient(var(--shell-hi) 0 2px, var(--shell) 4px 15px, var(--shell-hi) 16.5px, transparent 16.5px),
        linear-gradient(transparent 17px, #2a2c3c 18px, var(--pad) 21px, #0b0c12);
      transform: rotateY(calc(var(--i) * #{math.div(360deg, $nc)})) translateZ($rc);

      // the seam light: its own layer so only opacity changes on hover
      &::after {
        content: '';
        position: absolute;
        inset: 16px 0 auto;
        height: 1.5px;
        background: var(--glow);
        opacity: 0.25;
        transition: opacity 0.5s;
      }
    }
  }

  &__outer,
  &__cushion {
    position: absolute;
    left: -$rc - 0.5px;
    top: -$rc - 0.5px;
    width: 2 * $rc + 1px;
    height: 2 * $rc + 1px;
    border-radius: 50%;
    backface-visibility: hidden;
  }

  // the outside of the cup: brushed metal plate, logo, light ring
  &__outer {
    background:
      radial-gradient(circle, var(--metal) 0 2.5px, transparent 3px),
      radial-gradient(circle, transparent 0 7px, var(--metal) 7.5px 8.5px, transparent 9px),
      radial-gradient(circle, transparent 0 83%, var(--metal-dark) 85%, #eef0fa 90%, var(--metal) 94%, var(--metal-dark) 100%),
      repeating-conic-gradient(rgb(255 255 255 / 0.04) 0 2deg, rgb(0 0 0 / 0.06) 2deg 4deg),
      radial-gradient(circle at 35% 30%, var(--shell-hi), var(--shell) 60%, color-mix(in srgb, var(--shell) 60%, #000));
    transform: rotateX(90deg) translateZ(0.5 * $t);

    em {
      position: absolute;
      inset: 9px;
      border: 2px solid var(--glow);
      border-radius: 50%;
      box-shadow:
        0 0 8px var(--glow),
        inset 0 0 8px var(--glow);
      opacity: 0.2;
      transition: opacity 0.5s;
    }
  }

  // the inside: a soft cushion ring around the speaker cloth
  &__cushion {
    background:
      radial-gradient(circle, rgb(255 255 255 / 0.1) 0 1px, transparent 1.5px) 0 0 / 4px 4px,
      radial-gradient(circle, #06070b 0 52%, #2a2c3c 60%, var(--pad) 72%, #2a2c3c 86%, #0b0c12 100%);
    transform: rotateX(-90deg) translateZ(0.5 * $t);
  }

  // --- hover / focus: face the viewer, light up -------------------------------------------
  &:hover &__rig,
  &:focus-visible &__rig {
    --d-headphones-k: 0; // the lap keeps running underneath, it just stops counting
  }

  &:hover &__outer em,
  &:focus-visible &__outer em,
  &:hover &__cup > i::after,
  &:focus-visible &__cup > i::after {
    opacity: 1;
  }
}

// -180 → 180, not 0 → 360: the blend back to the pose is never more than half a turn
@keyframes d-headphones-turn {
  from {
    --d-headphones-spin: -180deg;
  }

  to {
    --d-headphones-spin: 180deg;
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.