Pure CSS Cards & galleries #form-hack #controls #cards #ui

Hinged accordion in pure CSS

Radio buttons open one section at a time: its panel swings down on its top edge like a flap while the headers below slide out of the way. No height is animated.

  • 132 lines of CSS
  • 19 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. Real <input type="radio">s (visually hidden, not display: none) drive the state — that keeps arrow-key navigation and screen readers working for free.
  2. A panel is a flap hinged on its top edge: transform-origin: top center plus rotateX(-90deg) to fold it flat, edge-on to the viewer, until its radio is checked.
  3. Nothing animates height. The frame has a fixed size; sections below the open one are pushed down with translateY by exactly one panel and its gap (76 units) instead.
  4. ~ (general sibling) plus :checked lets one radio style both its own section and every section after it, all from CSS alone.
  5. Every length is a multiple of one base unit, --u, so the accordion is the same share of a gallery card, the editor and a recording canvas. The widget is its own control, so it fills the model box and has no control row under it.

Key techniques

  • :checked ~ sibling selectors
  • rotateX on transform-origin: top
  • translateY instead of height
  • fixed-size frame

Copy-paste code

HTML 19 lines

<div class="scene">
  <div class="accordion">
    <input type="radio" name="acc" id="acc-0" checked />
    <input type="radio" name="acc" id="acc-1" />
    <input type="radio" name="acc" id="acc-2" />
    <div class="sec" style="--i:0">
      <label for="acc-0">Perspective</label>
      <div class="panel">The camera distance. Put it on the parent; smaller values look more dramatic.</div>
    </div>
    <div class="sec" style="--i:1">
      <label for="acc-1">Preserve-3d</label>
      <div class="panel">Without it, children are flattened into the plane of their parent.</div>
    </div>
    <div class="sec" style="--i:2">
      <label for="acc-2">Backface</label>
      <div class="panel">Hide the reverse side of a face so text never shows mirrored.</div>
    </div>
  </div>
</div>

CSS 132 lines

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

.accordion {
  position: relative;
  width: calc(196 * var(--u));
  height: calc(187 * var(--u)); /* 3 × (header 32 + gap 5) + panel 71 */
  transform-style: preserve-3d;
  transform: rotateY(-18deg) rotateX(4deg);
  /* the frame and the headers are coplanar, and coplanar boxes have no stable hit-test order in
     3D, so the frame itself could win a click in the middle of a header. Nothing but a header
     (and an open panel) takes the pointer. */
  pointer-events: none;
}

/* real radios keep it keyboard-friendly (arrow keys move between sections); only hidden */
.accordion > input {
  position: absolute;
  width: calc(1 * var(--u));
  height: calc(1 * var(--u));
  margin: 0;
  opacity: 0;
  pointer-events: none;
}

.sec {
  position: absolute;
  top: calc(var(--i) * 37 * var(--u));
  right: 0;
  left: 0;
  height: calc(32 * var(--u));
  transform-style: preserve-3d;
  transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}

.accordion label {
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* the header is its section's 32 units, border included */
  box-sizing: border-box;
  height: 100%;
  padding: 0 calc(12 * var(--u));
  border: calc(1 * var(--u)) solid rgb(140 150 220 / 0.34);
  border-radius: calc(9 * var(--u));
  background: color-mix(in srgb, #8b6cff 14%, #141830);
  color: #eceefb;
  font-size: calc(12 * var(--u));
  font-weight: 700;
  cursor: pointer;
  user-select: none;
  pointer-events: auto;
}

/* chevron */
.accordion label::after {
  content: '';
  width: calc(6 * var(--u));
  height: calc(6 * var(--u));
  border-right: calc(2 * var(--u)) solid #949bc0;
  border-bottom: calc(2 * var(--u)) solid #949bc0;
  transform: translateY(calc(-2 * var(--u))) rotate(45deg);
  transition: transform 0.4s;
}

/* the flap: hinged on its top edge, folded back flat (edge-on to the viewer) until opened */
.panel {
  position: absolute;
  top: calc(100% + 5 * var(--u));
  right: 0;
  left: 0;
  /* 71 units outside, padding and border included: the sections below slide down 76, the panel
     and its 5-unit gap */
  box-sizing: border-box;
  height: calc(71 * var(--u));
  padding: calc(9 * var(--u)) calc(12 * var(--u));
  border: calc(1 * var(--u)) solid color-mix(in srgb, #2ee6d6 60%, transparent);
  border-radius: calc(9 * var(--u));
  background: linear-gradient(180deg, color-mix(in srgb, #2ee6d6 26%, #141830), color-mix(in srgb, #8b6cff 16%, #141830));
  color: #eceefb;
  font-size: calc(11 * var(--u));
  line-height: 1.45;
  opacity: 0;
  pointer-events: none;
  transform-origin: top center;
  transform: rotateX(-90deg);
  transition:
    transform 0.5s cubic-bezier(0.4, 0, 0.2, 1),
    opacity 0.3s;
}

/* the open section: flap down, header lit (each radio only ever targets its own section) */
#acc-0:checked ~ .sec:nth-of-type(1) .panel,
#acc-1:checked ~ .sec:nth-of-type(2) .panel,
#acc-2:checked ~ .sec:nth-of-type(3) .panel {
  opacity: 1;
  pointer-events: auto;
  transform: rotateX(0deg);
}

#acc-0:checked ~ .sec:nth-of-type(1) label,
#acc-1:checked ~ .sec:nth-of-type(2) label,
#acc-2:checked ~ .sec:nth-of-type(3) label {
  border-color: #8b6cff;
  background: color-mix(in srgb, #8b6cff 38%, #141830);
}

#acc-0:checked ~ .sec:nth-of-type(1) label::after,
#acc-1:checked ~ .sec:nth-of-type(2) label::after,
#acc-2:checked ~ .sec:nth-of-type(3) label::after {
  transform: translateY(calc(1 * var(--u))) rotate(-135deg);
}

#acc-0:focus-visible ~ .sec:nth-of-type(1) label,
#acc-1:focus-visible ~ .sec:nth-of-type(2) label,
#acc-2:focus-visible ~ .sec:nth-of-type(3) label {
  outline: calc(2 * var(--u)) solid #2ee6d6;
  outline-offset: calc(2 * var(--u));
}

/* every section after the open one slides down by exactly one panel's height */
#acc-0:checked ~ .sec:nth-of-type(n + 2) {
  transform: translateY(calc(76 * var(--u)));
}

#acc-1:checked ~ .sec:nth-of-type(n + 3) {
  transform: translateY(calc(76 * var(--u)));
}
Standalone snippet · plain CSS, no build step, no dependencies.