Pure CSS Buttons & forms #hover #ui #expands

Fold-down menu in pure CSS

At rest it is only its Menu bar. Hover or focus it: the items swing down from their top edge, one after another.

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

How it works

  1. At rest it is only the "Menu" bar, as a real menu is. Each item hangs from its top edge (transform-origin: top) folded up out of sight, rotateX(-90deg) with opacity: 0, and gives back the room it takes with a negative margin-bottom of its own height, so the folded list is no height at all. The items set their own light text colour, because their dark panel is dark on a light stage too.
  2. On :hover / :focus of the menu they swing down flat to 0° and take their room back (margin-bottom: 0), with the same duration and easing, so each row grows exactly as its item swings into it.
  3. transition-delay: calc(var(--i) * 80ms) opens them one after another.
  4. The non-hover rule uses the reversed delay, so closing runs bottom-up. perspective on the list gives the swing its depth.
  5. Every length is a multiple of one base unit, --u, tied to the canvas, text included, so the menu is the same share of a gallery card, the editor and a recording canvas. The stage centres the menu by its height, so the bar alone is centred at rest and the whole open menu is centred open: as the rows grow the bar rises by half of what they add, in step with them, with no motion of its own. The list has no gap under the bar (padding, not margin), so the pointer stays on the menu as it rises.

Key techniques

  • rotateX(-90deg) → 0 with origin top
  • transition-delay from --i
  • tabindex + :focus for keyboards

Copy-paste code

HTML 9 lines

<div class="menu" tabindex="0">
  <span>Menu</span>
  <ul>
    <li style="--i:0">Profile</li>
    <li style="--i:1">Projects</li>
    <li style="--i:2">Settings</li>
    <li style="--i:3">Sign out</li>
  </ul>
</div>

CSS 75 lines

.menu {
  /* one base unit: every length below is a multiple of it, so the menu is the same share of a
     card, the editor, a full screen and a recording canvas */
  --u: 0.29vmin;
  width: calc(200 * var(--u));
  cursor: pointer;
  font: calc(16 * var(--u)) Inter, system-ui, sans-serif;
}

.menu > span {
  display: block;
  padding: calc(12 * var(--u)) calc(16 * var(--u));
  border-radius: calc(10 * var(--u));
  color: #fff;
  font-weight: 800;
  /* the brand gradient deep enough for white words (5.1:1 at its worst end): the bright #8b6cff to
     #ff4d9d was 3.7:1 */
  background: linear-gradient(120deg, #6a45f5, #d1206f);
}

/* the caret is drawn, not typed: ▾ is in neither font an export carries */
.menu > span::after {
  content: '';
  display: inline-block;
  width: 0.6em;
  height: 0.38em;
  margin-left: 0.4em;
  vertical-align: 0.12em;
  background: currentColor;
  clip-path: polygon(0 0, 100% 0, 50% 100%);
}

/* the list is in the flow, so the menu is as tall as what is open of it and the stage centres
   exactly that: the bar alone at rest, bar and list open */
.menu ul {
  margin: 0;
  /* the gap under the bar is the list's own padding, so the pointer never falls between the two
     as the menu rises; folded, the list takes no pointer, so hovering under the bar opens nothing */
  padding: calc(4 * var(--u)) 0 0;
  list-style: none;
  perspective: calc(500 * var(--u));
  pointer-events: none;
}

.menu:hover ul,
.menu:focus ul {
  pointer-events: auto;
}

.menu li {
  padding: calc(10 * var(--u)) calc(16 * var(--u));
  line-height: calc(20 * var(--u)); /* with the padding and border, 42 units tall */
  background: #141830;
  border: calc(1 * var(--u)) solid #2a3054;
  color: #eceefb; /* its own colour: the panel is dark on a light stage too */
  opacity: 0;
  transform-origin: top center;
  /* at rest the items are folded up out of sight and give back their room, so the menu is only
     its bar; hover swings them down from their top edge as their rows grow */
  transform: rotateX(-90deg);
  margin-bottom: calc(-42 * var(--u));
  transition:
    transform 0.35s cubic-bezier(0.3, 1.4, 0.5, 1),
    margin-bottom 0.35s cubic-bezier(0.3, 1.4, 0.5, 1),
    opacity 0.2s;
  transition-delay: calc((3 - var(--i)) * 50ms);   /* closing: bottom-up */
}

.menu:hover li,
.menu:focus li {
  opacity: 1;
  transform: rotateX(0deg);
  margin-bottom: 0;
  transition-delay: calc(var(--i) * 80ms);         /* opening: top-down */
}
Standalone snippet · plain CSS, no build step, no dependencies.