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.

  • 119 lines of CSS
  • 19 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed
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's height instead.
  4. ~ (general sibling) plus :checked lets one radio style both its own section and every section after it, all from CSS alone.

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

.scene {
  perspective: 800px;
}

.accordion {
  position: relative;
  width: 196px;
  height: 187px; /* 3 × (header 32px + gap 5px) + panel 71px */
  transform-style: preserve-3d;
  transform: rotateY(-18deg) rotateX(4deg);
}

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

.sec {
  position: absolute;
  top: calc(var(--i) * 37px);
  right: 0;
  left: 0;
  height: 32px;
  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;
  height: 100%;
  padding: 0 12px;
  border: 1px solid rgb(140 150 220 / 0.34);
  border-radius: 9px;
  background: color-mix(in srgb, #8b6cff 14%, #141830);
  color: #eceefb;
  font-size: 12px;
  font-weight: 700;
  cursor: pointer;
  user-select: none;
}

/* chevron */
.accordion label::after {
  content: '';
  width: 6px;
  height: 6px;
  border-right: 2px solid #949bc0;
  border-bottom: 2px solid #949bc0;
  transform: translateY(-2px) 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% + 5px);
  right: 0;
  left: 0;
  height: 71px;
  padding: 9px 12px;
  border: 1px solid color-mix(in srgb, #2ee6d6 60%, transparent);
  border-radius: 9px;
  background: linear-gradient(180deg, color-mix(in srgb, #2ee6d6 26%, #141830), color-mix(in srgb, #8b6cff 16%, #141830));
  color: #eceefb;
  font-size: 11px;
  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(1px) 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: 2px solid #2ee6d6;
  outline-offset: 2px;
}

/* 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(76px);
}

#acc-1:checked ~ .sec:nth-of-type(n + 3) {
  transform: translateY(76px);
}

Sass source 120 lines

$head: 32px; // header height
$gap: 5px;
$panel: 76px; // panel height
$step: $head + $gap;

// Nothing here animates height. The frame has a fixed size; headers slide with translateY and
// panels swing on their top edge with rotateX, so the whole accordion runs on the compositor.
.d-accordion {
  position: relative;
  width: 196px;
  height: $step * 3 + $panel;
  transform-style: preserve-3d;
  transform: rotateY(-18deg) rotateX(4deg);

  // Real radios keep it keyboard-friendly (arrow keys move between sections); they are only hidden.
  > input {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: 0;
    opacity: 0;
    pointer-events: none;
  }

  &__sec {
    position: absolute;
    top: calc(var(--i) * #{$step});
    right: 0;
    left: 0;
    height: $head;
    transform-style: preserve-3d;
    transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
  }

  label {
    display: flex;
    align-items: center;
    justify-content: space-between;
    height: 100%;
    padding: 0 12px;
    border: 1px solid var(--border-strong);
    border-radius: 9px;
    background: color-mix(in srgb, var(--accent) 14%, var(--surface-solid));
    color: var(--text);
    font-size: 12px;
    font-weight: 700;
    cursor: pointer;
    user-select: none;

    // chevron
    &::after {
      content: '';
      width: 6px;
      height: 6px;
      border-right: 2px solid var(--muted);
      border-bottom: 2px solid var(--muted);
      transform: translateY(-2px) 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% + #{$gap});
    right: 0;
    left: 0;
    height: $panel - $gap;
    padding: 9px 12px;
    border: 1px solid color-mix(in srgb, var(--accent-2) 60%, transparent);
    border-radius: 9px;
    background: linear-gradient(
      180deg,
      color-mix(in srgb, var(--accent-2) 26%, var(--surface-solid)),
      color-mix(in srgb, var(--accent) 16%, var(--surface-solid))
    );
    color: var(--text);
    font-size: 11px;
    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;
  }

  @for $i from 1 through 3 {
    // the open section: flap down, header lit
    & > input:nth-of-type(#{$i}):checked ~ &__sec:nth-of-type(#{$i}) {
      .d-accordion__panel {
        opacity: 1;
        pointer-events: auto;
        transform: rotateX(0deg);
      }

      label {
        border-color: var(--accent);
        background: color-mix(in srgb, var(--accent) 38%, var(--surface-solid));

        &::after {
          transform: translateY(1px) rotate(-135deg);
        }
      }
    }

    & > input:nth-of-type(#{$i}):focus-visible ~ &__sec:nth-of-type(#{$i}) label {
      outline: 2px solid var(--accent-2);
      outline-offset: 2px;
    }

    // every section after the open one slides down by exactly one panel
    @if $i < 3 {
      & > input:nth-of-type(#{$i}):checked ~ &__sec:nth-of-type(n + #{$i + 1}) {
        transform: translateY($panel);
      }
    }
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.