Four small app windows in sequence: three menu lines, the lines starting to turn, a finished X, and an X inside a dotted gold seal, linked by dotted gold arrows.

10 CSS hamburger menu animations for university websites

Ten accessible hamburger-to-X animations for higher education sites, from a spring-loaded X to a drawn seal, with the CSS for each.

On a phone, a university homepage puts almost all of its navigation behind one button in the corner. For a lot of visitors that button is the first thing they press. Most sites answer that press with three lines snapping into an X, or with nothing at all until the menu slides in.

That three-lines-to-X turn is everywhere, so I left it out. These ten are alternatives you don’t see often, ordered from the quietest to the most crafted. Each plays once as it scrolls into view and again when you hover its card. Press any button to run it yourself, switch to quarter speed to see what each line is doing, and open “View code” for the CSS or Sass.

  1. 1Spring settle

    CSS linear()

    The X turns in on a spring, passes 45° by a few degrees and settles.

    700 ms

    1Spring settle

    @charset "UTF-8";
    /* A damped spring sampled into linear(): it passes 45° by about 10%, about 4.5°,
    then settles. Only the turn springs; the lines reach the centre on a plain curve. */
    .menu-button--spring {
    --spring: linear(
    0, 0.046, 0.161, 0.312, 0.474, 0.631, 0.77, 0.884, 0.972, 1.035, 1.074, 1.095,
    1.101, 1.096, 1.084, 1.069, 1.053, 1.037, 1.023, 1.011, 1.003, 0.996, 0.992, 0.99, 1
    );
    }
    .menu-button--spring .line {
    transition:
    translate 340ms var(--ease-out),
    rotate 340ms var(--ease-out),
    opacity 200ms ease;
    }
    .menu-button--spring[aria-expanded=true] .line {
    transition:
    translate 300ms var(--ease-out),
    rotate 640ms var(--spring) 60ms,
    opacity 200ms ease;
    }
    // A damped spring sampled into linear(): it passes 45° by about 10%, about 4.5°,
    // then settles. Only the turn springs; the lines reach the centre on a plain curve.
    .menu-button--spring {
    --spring: linear(
    0, 0.046, 0.161, 0.312, 0.474, 0.631, 0.77, 0.884, 0.972, 1.035, 1.074, 1.095,
    1.101, 1.096, 1.084, 1.069, 1.053, 1.037, 1.023, 1.011, 1.003, 0.996, 0.992, 0.99, 1
    );
    .line {
    transition:
    translate 340ms var(--ease-out),
    rotate 340ms var(--ease-out),
    opacity 200ms ease;
    }
    &[aria-expanded='true'] .line {
    transition:
    translate 300ms var(--ease-out),
    rotate 640ms var(--spring) 60ms,
    opacity 200ms ease;
    }
    }
  2. 2Point bloom

    CSS

    The lines gather into a dot, and the X grows out of it.

    680 ms

    2Point bloom

    /* The lines gather into a 2px dot at the centre, and the X grows out of that dot.
    The dot holds for a beat, and both halves scale to exactly its size, so the swap is seamless. */
    .menu-button--bloom .line {
    transition:
    translate 280ms var(--ease-in-out) 340ms,
    scale 280ms var(--ease-in-out) 340ms,
    opacity 0s linear 340ms;
    }
    .menu-button--bloom .x-line {
    scale: 0.0909090909 1;
    visibility: hidden;
    transition:
    scale 340ms var(--ease-in-out),
    visibility 0s linear 340ms;
    }
    .menu-button--bloom[aria-expanded=true] .line {
    scale: 0.0909090909 1;
    opacity: 0;
    transition:
    translate 280ms var(--ease-in-out),
    scale 280ms var(--ease-in-out),
    opacity 0s linear 340ms;
    }
    .menu-button--bloom[aria-expanded=true] .line-1 {
    translate: 0 7px;
    }
    .menu-button--bloom[aria-expanded=true] .line-3 {
    translate: 0 -7px;
    }
    .menu-button--bloom[aria-expanded=true] .x-line {
    scale: 1 1;
    visibility: visible;
    transition:
    scale 340ms var(--ease-out) 340ms,
    visibility 0s linear 340ms;
    }
    // The lines gather into a 2px dot at the centre, and the X grows out of that dot.
    // The dot holds for a beat, and both halves scale to exactly its size, so the swap is seamless.
    .menu-button--bloom {
    $dot: calc(2 / 22);
    $gather: 280ms;
    $grow: 340ms;
    .line {
    transition:
    translate $gather var(--ease-in-out) $grow,
    scale $gather var(--ease-in-out) $grow,
    opacity 0s linear $grow;
    }
    .x-line {
    scale: $dot 1;
    visibility: hidden;
    transition:
    scale $grow var(--ease-in-out),
    visibility 0s linear $grow;
    }
    &[aria-expanded='true'] {
    .line {
    scale: $dot 1;
    opacity: 0;
    transition:
    translate $gather var(--ease-in-out),
    scale $gather var(--ease-in-out),
    opacity 0s linear $gather + 60ms;
    }
    .line-1 { translate: 0 7px; }
    .line-3 { translate: 0 -7px; }
    .x-line {
    scale: 1 1;
    visibility: visible;
    transition:
    scale $grow var(--ease-out) $gather + 60ms,
    visibility 0s linear $gather + 60ms;
    }
    }
    }
  3. 3Fold

    CSS

    The outer lines fold at the middle and meet as an X.

    420 ms

    3Fold

    /* The outer lines are each split into two halves hinged at the middle. Each half turns
    on its inner end, so the top line folds into a V, the bottom into an inverted V, and
    the two points meet in the centre as an X. */
    .menu-button--fold .half {
    width: 50%;
    transition:
    translate 420ms var(--ease-in-out),
    rotate 420ms var(--ease-in-out);
    }
    .menu-button--fold .half-left {
    border-radius: 2px 0 0 2px;
    transform-origin: right center;
    }
    .menu-button--fold .half-right {
    left: 50%;
    border-radius: 0 2px 2px 0;
    transform-origin: left center;
    }
    /* The middle line draws into the hinge as the halves arrive. */
    .menu-button--fold .line-2 {
    transition: scale 300ms var(--ease-in-out) 120ms;
    }
    .menu-button--fold[aria-expanded=true] .line-1 {
    translate: 0 7px;
    }
    .menu-button--fold[aria-expanded=true] .line-3 {
    translate: 0 -7px;
    }
    .menu-button--fold[aria-expanded=true] .line-1.half-left,
    .menu-button--fold[aria-expanded=true] .line-3.half-right {
    rotate: 45deg;
    }
    .menu-button--fold[aria-expanded=true] .line-1.half-right,
    .menu-button--fold[aria-expanded=true] .line-3.half-left {
    rotate: -45deg;
    }
    .menu-button--fold[aria-expanded=true] .line-2 {
    scale: 0 1;
    transition: scale 300ms var(--ease-in-out);
    }
    // The outer lines are each split into two halves hinged at the middle. Each half turns
    // on its inner end, so the top line folds into a V, the bottom into an inverted V, and
    // the two points meet in the centre as an X.
    .menu-button--fold {
    $duration: 420ms;
    .half {
    width: 50%;
    transition:
    translate $duration var(--ease-in-out),
    rotate $duration var(--ease-in-out);
    }
    .half-left {
    border-radius: 2px 0 0 2px;
    transform-origin: right center;
    }
    .half-right {
    left: 50%;
    border-radius: 0 2px 2px 0;
    transform-origin: left center;
    }
    // The middle line draws into the hinge as the halves arrive.
    .line-2 {
    transition: scale 300ms var(--ease-in-out) 120ms;
    }
    &[aria-expanded='true'] {
    .line-1 { translate: 0 7px; }
    .line-3 { translate: 0 -7px; }
    .line-1.half-left,
    .line-3.half-right { rotate: 45deg; }
    .line-1.half-right,
    .line-3.half-left { rotate: -45deg; }
    .line-2 {
    scale: 0 1;
    transition: scale 300ms var(--ease-in-out);
    }
    }
    }
  4. 4Converge

    CSS

    The lines thin away as the arms of the X slide in from the corners.

    640 ms

    4Converge

    /* The X is four half-arms that rest 5px out along their own diagonals, hidden.
    Opening thins the lines away and draws the arms in to meet at the centre. */
    .menu-button--converge .line {
    transition:
    scale 300ms var(--ease-in-out) 160ms,
    opacity 300ms ease 160ms;
    }
    .menu-button--converge .arm {
    position: absolute;
    top: 7px;
    width: 50%;
    height: 2px;
    background: currentColor;
    opacity: 0;
    transition:
    translate 300ms var(--ease-in-out) var(--delay, 0s),
    opacity 300ms ease var(--delay, 0s);
    }
    .menu-button--converge .arm-1,
    .menu-button--converge .arm-4 {
    left: 0;
    border-radius: 2px 0 0 2px;
    transform-origin: right center;
    }
    .menu-button--converge .arm-2,
    .menu-button--converge .arm-3 {
    left: 50%;
    border-radius: 0 2px 2px 0;
    transform-origin: left center;
    }
    .menu-button--converge .arm-1 {
    rotate: 45deg;
    translate: -5px -5px;
    }
    .menu-button--converge .arm-2 {
    rotate: -45deg;
    translate: 5px -5px;
    --delay: 40ms;
    }
    .menu-button--converge .arm-3 {
    rotate: 45deg;
    translate: 5px 5px;
    --delay: 80ms;
    }
    .menu-button--converge .arm-4 {
    rotate: -45deg;
    translate: -5px 5px;
    --delay: 120ms;
    }
    .menu-button--converge[aria-expanded=true] .line {
    scale: 1 0;
    opacity: 0;
    transition:
    scale 280ms var(--ease-in-out),
    opacity 280ms ease;
    }
    .menu-button--converge[aria-expanded=true] .arm {
    translate: 0 0;
    opacity: 1;
    transition:
    translate 400ms var(--ease-out) calc(var(--delay, 0s) + 120ms),
    opacity 240ms ease calc(var(--delay, 0s) + 120ms);
    }
    // The X is four half-arms that rest 5px out along their own diagonals, hidden.
    // Opening thins the lines away and draws the arms in to meet at the centre.
    .menu-button--converge {
    .line {
    transition:
    scale 300ms var(--ease-in-out) 160ms,
    opacity 300ms ease 160ms;
    }
    .arm {
    position: absolute;
    top: 7px;
    width: 50%;
    height: 2px;
    background: currentColor;
    opacity: 0;
    transition:
    translate 300ms var(--ease-in-out) var(--delay, 0s),
    opacity 300ms ease var(--delay, 0s);
    }
    .arm-1,
    .arm-4 {
    left: 0;
    border-radius: 2px 0 0 2px;
    transform-origin: right center;
    }
    .arm-2,
    .arm-3 {
    left: 50%;
    border-radius: 0 2px 2px 0;
    transform-origin: left center;
    }
    .arm-1 { rotate: 45deg; translate: -5px -5px; }
    .arm-2 { rotate: -45deg; translate: 5px -5px; --delay: 40ms; }
    .arm-3 { rotate: 45deg; translate: 5px 5px; --delay: 80ms; }
    .arm-4 { rotate: -45deg; translate: -5px 5px; --delay: 120ms; }
    &[aria-expanded='true'] {
    .line {
    scale: 1 0;
    opacity: 0;
    transition:
    scale 280ms var(--ease-in-out),
    opacity 280ms ease;
    }
    .arm {
    translate: 0 0;
    opacity: 1;
    transition:
    translate 400ms var(--ease-out) calc(var(--delay, 0s) + 120ms),
    opacity 240ms ease calc(var(--delay, 0s) + 120ms);
    }
    }
    }
  5. 5Handoff

    CSS

    The lines slide out to the right as the X slides in from the left.

    600 ms

    5Handoff

    /* The icon clips at its sides only, so the lines leave on the right as the X
    arrives from the left, while the X's corners still clear the top and bottom. */
    .menu-button--handoff .menu-icon {
    clip-path: inset(-4px 0);
    }
    .menu-button--handoff .line {
    transition: translate 320ms var(--ease-in-out) var(--delay, 0s);
    }
    .menu-button--handoff .line-1 {
    --delay: 200ms;
    }
    .menu-button--handoff .line-2 {
    --delay: 150ms;
    }
    .menu-button--handoff .line-3 {
    --delay: 100ms;
    }
    .menu-button--handoff .x-line {
    translate: -24px 0;
    transition: translate 320ms var(--ease-in-out) var(--delay, 0s);
    }
    .menu-button--handoff .x-line-2 {
    --delay: 60ms;
    }
    .menu-button--handoff[aria-expanded=true] .line {
    translate: 24px 0;
    }
    .menu-button--handoff[aria-expanded=true] .line-1 {
    --delay: 0s;
    }
    .menu-button--handoff[aria-expanded=true] .line-2 {
    --delay: 50ms;
    }
    .menu-button--handoff[aria-expanded=true] .line-3 {
    --delay: 100ms;
    }
    .menu-button--handoff[aria-expanded=true] .x-line {
    translate: 0 0;
    transition: translate 380ms var(--ease-out) var(--delay, 0s);
    }
    .menu-button--handoff[aria-expanded=true] .x-line-1 {
    --delay: 160ms;
    }
    .menu-button--handoff[aria-expanded=true] .x-line-2 {
    --delay: 220ms;
    }
    // The icon clips at its sides only, so the lines leave on the right as the X
    // arrives from the left, while the X's corners still clear the top and bottom.
    .menu-button--handoff {
    .menu-icon {
    clip-path: inset(-4px 0);
    }
    .line {
    transition: translate 320ms var(--ease-in-out) var(--delay, 0s);
    }
    .line-1 { --delay: 200ms; }
    .line-2 { --delay: 150ms; }
    .line-3 { --delay: 100ms; }
    .x-line {
    translate: -24px 0;
    transition: translate 320ms var(--ease-in-out) var(--delay, 0s);
    }
    .x-line-2 { --delay: 60ms; }
    &[aria-expanded='true'] {
    .line {
    translate: 24px 0;
    }
    .line-1 { --delay: 0s; }
    .line-2 { --delay: 50ms; }
    .line-3 { --delay: 100ms; }
    .x-line {
    translate: 0 0;
    transition: translate 380ms var(--ease-out) var(--delay, 0s);
    }
    .x-line-1 { --delay: 160ms; }
    .x-line-2 { --delay: 220ms; }
    }
    }
  6. 6Labelled

    CSS

    “Menu” turns to “Close” one letter at a time as the icon turns.

    440 ms

    6Labelled

    .menu-button--labelled {
    width: auto;
    grid-auto-flow: column;
    gap: 10px;
    padding: 0 10px 0 12px;
    }
    .menu-button--labelled .line {
    transition:
    translate 340ms var(--ease-out),
    rotate 340ms var(--ease-out),
    opacity 200ms ease;
    }
    /* "Menu" and "Close" share one cell in a one-line window. Each letter carries its
    index as --i, so one rule staggers the swap from left to right. */
    .menu-button--labelled .menu-words {
    display: grid;
    height: 1.25em;
    overflow: hidden;
    font-size: 14px;
    font-weight: 600;
    line-height: 1.25;
    }
    .menu-button--labelled .menu-words > span {
    grid-area: 1/1;
    }
    .menu-button--labelled .menu-words i {
    display: inline-block;
    font-style: normal;
    transition: translate 320ms var(--ease-out) calc(var(--i) * 30ms);
    }
    .menu-button--labelled .menu-words-close i {
    translate: 0 100%;
    }
    .menu-button--labelled[aria-expanded=true] .menu-words-menu i {
    translate: 0 -100%;
    }
    .menu-button--labelled[aria-expanded=true] .menu-words-close i {
    translate: 0 0;
    }
    .menu-button--labelled {
    width: auto;
    grid-auto-flow: column;
    gap: 10px;
    padding: 0 10px 0 12px;
    .line {
    transition:
    translate 340ms var(--ease-out),
    rotate 340ms var(--ease-out),
    opacity 200ms ease;
    }
    // "Menu" and "Close" share one cell in a one-line window. Each letter carries its
    // index as --i, so one rule staggers the swap from left to right.
    .menu-words {
    display: grid;
    height: 1.25em;
    overflow: hidden;
    font-size: 14px;
    font-weight: 600;
    line-height: 1.25;
    > span {
    grid-area: 1 / 1;
    }
    i {
    display: inline-block;
    font-style: normal;
    transition: translate 320ms var(--ease-out) calc(var(--i) * 30ms);
    }
    }
    .menu-words-close i {
    translate: 0 100%;
    }
    &[aria-expanded='true'] {
    .menu-words-menu i {
    translate: 0 -100%;
    }
    .menu-words-close i {
    translate: 0 0;
    }
    }
    }
  7. 7Ring

    CSS and SVG

    A thin gold ring draws around the finished X.

    720 ms

    7Ring

    .menu-button--ring .line {
    transition:
    translate 340ms var(--ease-out),
    rotate 340ms var(--ease-out),
    opacity 200ms ease;
    }
    /* Starts the draw at twelve o'clock. */
    .menu-button--ring .ring {
    position: absolute;
    inset: 0;
    width: 48px;
    height: 48px;
    rotate: -90deg;
    pointer-events: none;
    }
    /* The dash runs 2% past the circle so the join never shows a seam,
    and visibility hides the resting dash, which still paints a dot. */
    .menu-button--ring .ring circle {
    fill: none;
    stroke: var(--accent);
    stroke-width: 1.25;
    stroke-dasharray: 1.02;
    stroke-dashoffset: 1.02;
    visibility: hidden;
    transition:
    stroke-dashoffset 240ms ease-in,
    visibility 240ms;
    }
    .menu-button--ring[aria-expanded=true] .ring circle {
    stroke-dashoffset: 0;
    visibility: visible;
    transition:
    stroke-dashoffset 520ms var(--ease-out) 200ms,
    visibility 520ms linear 200ms;
    }
    .menu-button--ring {
    .line {
    transition:
    translate 340ms var(--ease-out),
    rotate 340ms var(--ease-out),
    opacity 200ms ease;
    }
    // Starts the draw at twelve o'clock.
    .ring {
    position: absolute;
    inset: 0;
    width: 48px;
    height: 48px;
    rotate: -90deg;
    pointer-events: none;
    }
    // The dash runs 2% past the circle so the join never shows a seam,
    // and visibility hides the resting dash, which still paints a dot.
    .ring circle {
    fill: none;
    stroke: var(--accent);
    stroke-width: 1.25;
    stroke-dasharray: 1.02;
    stroke-dashoffset: 1.02;
    visibility: hidden;
    transition:
    stroke-dashoffset 240ms ease-in,
    visibility 240ms;
    }
    &[aria-expanded='true'] .ring circle {
    stroke-dashoffset: 0;
    visibility: visible;
    transition:
    stroke-dashoffset 520ms var(--ease-out) 200ms,
    visibility 520ms linear 200ms;
    }
    }
  8. 8Leaf

    3D CSS

    Lines turn away like pages and the X turns in.

    580 ms

    8Leaf

    /* Each line reads its delay from --delay, so the stagger flips
    between opening and closing by changing the numbers alone. */
    .menu-button--leaf .menu-icon {
    perspective: 80px;
    }
    .menu-button--leaf .line {
    backface-visibility: hidden;
    transition:
    rotate 280ms var(--ease-in-out) var(--delay, 0s),
    opacity 0s linear var(--delay, 0s);
    }
    .menu-button--leaf .line-1 {
    --delay: 200ms;
    }
    .menu-button--leaf .line-2 {
    --delay: 250ms;
    }
    .menu-button--leaf .line-3 {
    --delay: 300ms;
    }
    /* The X lines rest edge-on, which still paints a hairline, so they hide too. */
    .menu-button--leaf .leaf-x {
    position: absolute;
    left: 0;
    top: 7px;
    width: 100%;
    height: 2px;
    border-radius: 2px;
    background: currentColor;
    transform: rotateX(90deg);
    visibility: hidden;
    transition:
    transform 300ms var(--ease-out) var(--delay, 0s),
    visibility 300ms linear var(--delay, 0s);
    }
    .menu-button--leaf .leaf-x-1 {
    rotate: 45deg;
    --delay: 0s;
    }
    .menu-button--leaf .leaf-x-2 {
    rotate: -45deg;
    --delay: 60ms;
    }
    .menu-button--leaf[aria-expanded=true] .line {
    rotate: x -90deg;
    opacity: 0;
    transition:
    rotate 280ms var(--ease-in-out) var(--delay, 0s),
    opacity 80ms linear calc(var(--delay, 0s) + 200ms);
    }
    .menu-button--leaf[aria-expanded=true] .line-1 {
    --delay: 0s;
    }
    .menu-button--leaf[aria-expanded=true] .line-2 {
    --delay: 50ms;
    }
    .menu-button--leaf[aria-expanded=true] .line-3 {
    --delay: 100ms;
    }
    .menu-button--leaf[aria-expanded=true] .leaf-x {
    transform: rotateX(0deg);
    visibility: visible;
    }
    .menu-button--leaf[aria-expanded=true] .leaf-x-1 {
    --delay: 220ms;
    }
    .menu-button--leaf[aria-expanded=true] .leaf-x-2 {
    --delay: 280ms;
    }
    // Each line reads its delay from --delay, so the stagger flips
    // between opening and closing by changing the numbers alone.
    .menu-button--leaf {
    .menu-icon {
    perspective: 80px;
    }
    .line {
    backface-visibility: hidden;
    transition:
    rotate 280ms var(--ease-in-out) var(--delay, 0s),
    opacity 0s linear var(--delay, 0s);
    }
    .line-1 { --delay: 200ms; }
    .line-2 { --delay: 250ms; }
    .line-3 { --delay: 300ms; }
    // The X lines rest edge-on, which still paints a hairline, so they hide too.
    .leaf-x {
    position: absolute;
    left: 0;
    top: 7px;
    width: 100%;
    height: 2px;
    border-radius: 2px;
    background: currentColor;
    transform: rotateX(90deg);
    visibility: hidden;
    transition:
    transform 300ms var(--ease-out) var(--delay, 0s),
    visibility 300ms linear var(--delay, 0s);
    }
    .leaf-x-1 { rotate: 45deg; --delay: 0s; }
    .leaf-x-2 { rotate: -45deg; --delay: 60ms; }
    &[aria-expanded='true'] {
    .line {
    rotate: x -90deg;
    opacity: 0;
    transition:
    rotate 280ms var(--ease-in-out) var(--delay, 0s),
    opacity 80ms linear calc(var(--delay, 0s) + 200ms);
    }
    .line-1 { --delay: 0s; }
    .line-2 { --delay: 50ms; }
    .line-3 { --delay: 100ms; }
    .leaf-x {
    transform: rotateX(0deg);
    visibility: visible;
    }
    .leaf-x-1 { --delay: 220ms; }
    .leaf-x-2 { --delay: 280ms; }
    }
    }
  9. 9Pen strokes

    SVG

    Lines erase left to right, then the X is drawn.

    720 ms

    9Pen strokes

    /* pathLength="1" on every path keeps the dash maths independent of length. */
    .menu-button--pen .pen {
    width: 26px;
    height: 26px;
    overflow: visible;
    }
    .menu-button--pen .pen path {
    fill: none;
    stroke: currentColor;
    stroke-width: 2;
    stroke-linecap: round;
    stroke-dasharray: 1;
    transition:
    stroke-dashoffset var(--duration, 240ms) var(--ease-in-out) var(--delay, 0ms),
    visibility var(--duration, 240ms) linear var(--delay, 0ms);
    }
    /* Round caps paint a dot even on an empty dash, so hidden strokes use visibility. */
    .menu-button--pen .pen-cross {
    --duration: 200ms;
    stroke-dashoffset: 1;
    visibility: hidden;
    }
    .menu-button--pen .pen-line-1 {
    --delay: 200ms;
    }
    .menu-button--pen .pen-line-2 {
    --delay: 250ms;
    }
    .menu-button--pen .pen-line-3 {
    --delay: 300ms;
    }
    .menu-button--pen .pen-cross-2 {
    --delay: 60ms;
    }
    /* Offsetting to -1 erases each line in the direction it was drawn. */
    .menu-button--pen[aria-expanded=true] .pen-line {
    stroke-dashoffset: -1;
    visibility: hidden;
    }
    .menu-button--pen[aria-expanded=true] .pen-line-1 {
    --delay: 0ms;
    }
    .menu-button--pen[aria-expanded=true] .pen-line-2 {
    --delay: 60ms;
    }
    .menu-button--pen[aria-expanded=true] .pen-line-3 {
    --delay: 120ms;
    }
    .menu-button--pen[aria-expanded=true] .pen-cross {
    --duration: 300ms;
    stroke-dashoffset: 0;
    visibility: visible;
    }
    .menu-button--pen[aria-expanded=true] .pen-cross-1 {
    --delay: 260ms;
    }
    .menu-button--pen[aria-expanded=true] .pen-cross-2 {
    --delay: 420ms;
    }
    // pathLength="1" on every path keeps the dash maths independent of length.
    .menu-button--pen {
    .pen {
    width: 26px;
    height: 26px;
    overflow: visible;
    }
    .pen path {
    fill: none;
    stroke: currentColor;
    stroke-width: 2;
    stroke-linecap: round;
    stroke-dasharray: 1;
    transition:
    stroke-dashoffset var(--duration, 240ms) var(--ease-in-out) var(--delay, 0ms),
    visibility var(--duration, 240ms) linear var(--delay, 0ms);
    }
    // Round caps paint a dot even on an empty dash, so hidden strokes use visibility.
    .pen-cross {
    --duration: 200ms;
    stroke-dashoffset: 1;
    visibility: hidden;
    }
    .pen-line-1 { --delay: 200ms; }
    .pen-line-2 { --delay: 250ms; }
    .pen-line-3 { --delay: 300ms; }
    .pen-cross-2 { --delay: 60ms; }
    &[aria-expanded='true'] {
    // Offsetting to -1 erases each line in the direction it was drawn.
    .pen-line {
    stroke-dashoffset: -1;
    visibility: hidden;
    }
    .pen-line-1 { --delay: 0ms; }
    .pen-line-2 { --delay: 60ms; }
    .pen-line-3 { --delay: 120ms; }
    .pen-cross {
    --duration: 300ms;
    stroke-dashoffset: 0;
    visibility: visible;
    }
    .pen-cross-1 { --delay: 260ms; }
    .pen-cross-2 { --delay: 420ms; }
    }
    }
  10. 10Seal

    SVG

    Pen strokes plus 24 ticks setting like a seal’s rim.

    1160 ms

    10Seal

    /* Builds on Pen strokes: the button carries both classes. */
    .menu-button--seal .seal {
    position: absolute;
    inset: 0;
    width: 48px;
    height: 48px;
    pointer-events: none;
    rotate: -24deg;
    transition: rotate 320ms ease-in;
    }
    /* Each of the 24 ticks has its index in --i, so one rule staggers them all. */
    .menu-button--seal .seal line {
    stroke: var(--accent);
    stroke-width: 1.25;
    stroke-linecap: round;
    opacity: 0;
    scale: 0.6;
    transform-origin: 24px 24px;
    transform-box: view-box;
    transition:
    opacity 160ms ease calc((23 - var(--i)) * 5ms),
    scale 200ms ease calc((23 - var(--i)) * 5ms);
    }
    .menu-button--seal .seal circle {
    fill: none;
    stroke: var(--accent);
    stroke-width: 0.75;
    opacity: 0.55;
    stroke-dasharray: 1.02;
    stroke-dashoffset: 1.02;
    visibility: hidden;
    transition:
    stroke-dashoffset 240ms ease-in,
    visibility 240ms;
    }
    .menu-button--seal[aria-expanded=true] .seal {
    rotate: 0deg;
    transition: rotate 1000ms var(--ease-out) 160ms;
    }
    .menu-button--seal[aria-expanded=true] .seal line {
    opacity: 1;
    scale: 1;
    transition:
    opacity 220ms ease calc(200ms + var(--i) * 20ms),
    scale 380ms var(--ease-out) calc(200ms + var(--i) * 20ms);
    }
    .menu-button--seal[aria-expanded=true] .seal circle {
    stroke-dashoffset: 0;
    visibility: visible;
    transition:
    stroke-dashoffset 640ms var(--ease-out) 380ms,
    visibility 640ms linear 380ms;
    }
    // Builds on Pen strokes: the button carries both classes.
    .menu-button--seal {
    .seal {
    position: absolute;
    inset: 0;
    width: 48px;
    height: 48px;
    pointer-events: none;
    rotate: -24deg;
    transition: rotate 320ms ease-in;
    }
    // Each of the 24 ticks has its index in --i, so one rule staggers them all.
    .seal line {
    stroke: var(--accent);
    stroke-width: 1.25;
    stroke-linecap: round;
    opacity: 0;
    scale: 0.6;
    transform-origin: 24px 24px;
    transform-box: view-box;
    transition:
    opacity 160ms ease calc((23 - var(--i)) * 5ms),
    scale 200ms ease calc((23 - var(--i)) * 5ms);
    }
    .seal circle {
    fill: none;
    stroke: var(--accent);
    stroke-width: 0.75;
    opacity: 0.55;
    stroke-dasharray: 1.02;
    stroke-dashoffset: 1.02;
    visibility: hidden;
    transition:
    stroke-dashoffset 240ms ease-in,
    visibility 240ms;
    }
    &[aria-expanded='true'] {
    .seal {
    rotate: 0deg;
    transition: rotate 1000ms var(--ease-out) 160ms;
    }
    .seal line {
    opacity: 1;
    scale: 1;
    transition:
    opacity 220ms ease calc(200ms + var(--i) * 20ms),
    scale 380ms var(--ease-out) calc(200ms + var(--i) * 20ms);
    }
    .seal circle {
    stroke-dashoffset: 0;
    visibility: visible;
    transition:
    stroke-dashoffset 640ms var(--ease-out) 380ms,
    visibility 640ms linear 380ms;
    }
    }
    }

Animation theory

My first timings came from app conventions: 140 to 220 ms. On a university header they read as a flicker. Every main motion here now runs at least 300 ms, staged beats at least 200 ms, and closing runs a little faster than opening, since by then the visitor knows where they are going. I also softened the ease-out curve from cubic-bezier(0.16, 1, 0.3, 1) to cubic-bezier(0.25, 1, 0.5, 1), because the sharper one does most of its travel in the first few frames and turns even a long animation into a jump.

  • Feedback the eye can follow. Jakob Nielsen puts 0.1 seconds as about the limit for a change to feel instantaneous. Motion that finishes inside that window registers as a swap. Slower, a visitor sees the menu icon become the close icon, which is visibility of system status done with motion instead of a message.
  • The way out stays where the way in was. All ten change the same button in place, so the control someone just pressed is the one that closes the menu.
  • Slow in, slow out. Disney’s oldest animation principle. Point bloom adds anticipation by gathering the lines before the X appears, and Spring settle adds follow-through.
  • Polish is read as usability. People rate better-looking interfaces as easier to use, the aesthetic-usability effect. The menu button is often the first thing a visitor touches.
  • A wide audience. Prospective students, parents, staff, alumni and people on screen magnifiers. Motion they can all follow beats motion that feels fast to someone who builds websites.

The button they all share

The CSS keys every animation off aria-expanded, so there is no is-open class to drift out of sync with what a screen reader announces. Swap the modifier class to change the animation.

<button
type="button"
class="menu-button menu-button--spring"
aria-expanded="false"
aria-label="Menu"
aria-controls="site-menu"
>
<span class="menu-icon menu-icon--cross" aria-hidden="true">
<i class="line line-1"></i>
<i class="line line-2"></i>
<i class="line line-3"></i>
</span>
</button>

The lines use the individual translate, rotate and scale properties instead of one transform, so each can have its own duration and delay.

/* The button every animation shares. Each one only adds its own transitions. */
.menu-button {
--ease-out: cubic-bezier(0.25, 1, 0.5, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--accent: #cda35a;
position: relative;
display: inline-grid;
place-items: center;
width: 48px;
height: 48px;
padding: 0;
border: 0;
border-radius: 6px;
background: none;
color: #f4efe6;
cursor: pointer;
transition: color 160ms ease;
}
.menu-button:hover {
color: var(--accent);
}
.menu-button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.menu-icon {
position: relative;
display: block;
width: 22px;
height: 16px;
}
.line {
position: absolute;
left: 0;
width: 100%;
height: 2px;
border-radius: 2px;
background: currentColor;
}
.line-1 {
top: 0;
}
.line-2 {
top: 7px;
}
.line-3 {
top: 14px;
}
/* A finished X stroke, for animations that bring the X in as its own two lines. */
.x-line {
position: absolute;
left: 0;
top: 7px;
width: 100%;
height: 2px;
border-radius: 2px;
background: currentColor;
}
.x-line-1 {
rotate: 45deg;
}
.x-line-2 {
rotate: -45deg;
}
/* The shared X: the outer lines meet in the middle and cross, the middle one fades. */
.menu-button[aria-expanded=true] .menu-icon--cross .line-1 {
translate: 0 7px;
rotate: 45deg;
}
.menu-button[aria-expanded=true] .menu-icon--cross .line-2 {
opacity: 0;
}
.menu-button[aria-expanded=true] .menu-icon--cross .line-3 {
translate: 0 -7px;
rotate: -45deg;
}
/* Every animation becomes an instant swap. */
@media (prefers-reduced-motion: reduce) {
.menu-button,
.menu-button * {
transition-duration: 0s !important;
transition-delay: 0s !important;
}
}
// The button every animation shares. Each one only adds its own transitions.
.menu-button {
--ease-out: cubic-bezier(0.25, 1, 0.5, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--accent: #cda35a;
position: relative;
display: inline-grid;
place-items: center;
width: 48px;
height: 48px;
padding: 0;
border: 0;
border-radius: 6px;
background: none;
color: #f4efe6;
cursor: pointer;
transition: color 160ms ease;
&:hover {
color: var(--accent);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.menu-icon {
position: relative;
display: block;
width: 22px;
height: 16px;
}
.line {
position: absolute;
left: 0;
width: 100%;
height: 2px;
border-radius: 2px;
background: currentColor;
}
.line-1 { top: 0; }
.line-2 { top: 7px; }
.line-3 { top: 14px; }
// A finished X stroke, for animations that bring the X in as its own two lines.
.x-line {
position: absolute;
left: 0;
top: 7px;
width: 100%;
height: 2px;
border-radius: 2px;
background: currentColor;
}
.x-line-1 { rotate: 45deg; }
.x-line-2 { rotate: -45deg; }
// The shared X: the outer lines meet in the middle and cross, the middle one fades.
.menu-button[aria-expanded='true'] .menu-icon--cross {
.line-1 {
translate: 0 7px;
rotate: 45deg;
}
.line-2 {
opacity: 0;
}
.line-3 {
translate: 0 -7px;
rotate: -45deg;
}
}
// Every animation becomes an instant swap.
@media (prefers-reduced-motion: reduce) {
.menu-button,
.menu-button * {
transition-duration: 0s !important;
transition-delay: 0s !important;
}
}

Accessibility

The motion is the fun part, but these are the parts that matter to someone who can’t see it or doesn’t want it:

  • A real <button>, so it works with a keyboard and a switch device without extra script, with a visible focus ring.
  • One stable name and aria-expanded. A screen reader hears “Menu, collapsed” and then “Menu, expanded”, not a name that changes under them.
  • Hidden decoration. The lines, the SVGs and the “Menu” and “Close” words are all aria-hidden, so nothing is read twice.
  • Reduced motion. WCAG 2.3.3 asks that motion triggered by an interaction can be turned off. The shared base ends with one block that zeroes durations and delays, so each button still becomes an X, in one frame. Zeroing the delays matters as much as the durations, or the staggered ones still wait up to 420 ms before anything changes.

Picking one

Match the motion to how often the button gets pressed. On a main university site, where the menu is how most mobile visitors get around, stay with the quieter ones near the top of the list. A second-long seal is a lot to sit through on every press. Save the crafted ones for a campaign page or a microsite, where the menu gets opened once or twice.

Spring settle is the one I’d ship on a site that wants motion to stay out of the way. Labelled is the one I’d push hardest for, because a first-time visitor gets a word instead of a symbol. Seal belongs only on a site whose brand can carry it.

Whichever you pick, give it the same easing as the rest of the header, so the button, the menu panel and the search drawer move like they belong together.