Back to the library

Reorder, lift and settle

Variant: Lift and settle

A row picked up by its handle onto an ivory sheet with a shadow under it, dragged one slot, then set back down. The gap opens 32ms before the row starts moving — the list has to look like it already knows where the row is going. Nothing overshoots. This is the version for dense, work-heavy lists where a bounce would read as instability.

listgesture

The source

A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Reorder — lift and settle</title>
    <style>
      :root {
        --ivory: #faf9f5;
        --slate: #141413;
        --clay: #d97757;
        --oat: #e3dacc;
        --paper: #ffffff;
        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
      }

      * {
        box-sizing: border-box;
        margin: 0;
        padding: 0;
      }

      body {
        min-height: 100vh;
        display: grid;
        place-items: center;
        background: var(--ivory);
        font-family:
          system-ui,
          -apple-system,
          'Segoe UI',
          Roboto,
          sans-serif;
        color: var(--slate);
      }

      /* One 3.2s loop stages the whole gesture: press, lift, drag, settle, then
         the list dissolves so the reset is a fade rather than a visible
         un-reorder. In a real list, drive the same keyframes off pointer state. */
      .list {
        position: relative;
        width: 190px;
        height: 118px;
        animation: list-cycle 3.2s linear infinite;
      }

      .row {
        position: absolute;
        left: 0;
        width: 190px;
        height: 34px;
      }

      /* The lifted row holds the higher stacking context: the shadow is only
         convincing if the thing casting it is actually painted above. */
      .r1 {
        top: 0;
        z-index: 2;
        animation: drag-row 3.2s infinite;
      }
      .r2 {
        top: 42px;
        animation: gap-open 3.2s infinite;
      }
      .r3 {
        top: 84px;
      }

      /* The row owns vertical travel in 42px steps — 34px tall plus an 8px gap,
         so it always lands in a slot — and the card inside owns the lift scale.
         Split across two elements, each gets its own curve without a composite
         transform string to keep in sync. */
      .card {
        position: relative;
        height: 100%;
        display: flex;
        align-items: center;
        gap: 10px;
        padding: 0 12px;
        border-radius: 8px;
        background: var(--paper);
        border: 1px solid var(--oat);
      }

      .r1 .card {
        animation: lift 3.2s infinite;
      }

      .shadow,
      .sheet {
        position: absolute;
        inset: -1px;
        border-radius: 9px;
        opacity: 0;
      }

      .shadow {
        box-shadow:
          0 1px 2px rgba(20, 20, 19, 0.1),
          0 10px 22px -6px rgba(20, 20, 19, 0.28);
      }

      /* The lifted row is not tinted, only re-edged: the card is already the
         lightest surface in the list, so elevation is carried by the shadow and
         a clay border rather than by another colour. */
      .sheet {
        background: var(--paper);
        border: 1px solid rgba(217, 119, 87, 0.5);
      }

      .r1 .shadow,
      .r1 .sheet {
        animation: elevate 3.2s infinite;
      }

      .grip,
      .bar {
        position: relative;
      }

      .grip {
        width: 8px;
        height: 14px;
        flex: none;
        color: rgba(20, 20, 19, 0.34);
        background-image: radial-gradient(circle, currentColor 1.1px, transparent 1.3px);
        background-size: 4px 5px;
      }

      .r1 .grip {
        color: var(--clay);
      }

      .bar {
        height: 6px;
        border-radius: 3px;
        background: rgba(20, 20, 19, 0.16);
      }

      .r1 .bar {
        width: 74px;
      }
      .r2 .bar {
        width: 94px;
      }
      .r3 .bar {
        width: 58px;
      }

      .pointer {
        position: absolute;
        left: 3px;
        top: 50%;
        width: 24px;
        height: 24px;
        border-radius: 50%;
        background: rgba(20, 20, 19, 0.11);
        translate: 0 -50%;
        opacity: 0;
        animation: press 3.2s infinite;
      }

      /* The pointer lets go on contact, not after it — the hand is gone by the
         time the card has finished taking its own weight. */
      @keyframes press {
        0%,
        6% {
          opacity: 0;
          transform: scale(1.35);
          animation-timing-function: var(--ease-out);
        }
        9%,
        21% {
          opacity: 1;
          transform: scale(1);
        }
        25.5%,
        100% {
          opacity: 0;
          transform: scale(1);
        }
      }

      /* The landing is the whole point of the variant and it is not a fade back
         to rest: at 21% the row reaches its slot and the card compresses for
         64ms — 1.005 wide by 0.985 tall, a card taking back its own weight —
         then releases over 112ms. A lifted thing that returns to scale(1) on one
         long ease-out has been put down by nobody; the compression is what makes
         the surface underneath feel solid. Position never overshoots: this row
         was placed, not thrown, and that is what separates it from flick-throw. */
      @keyframes lift {
        0%,
        8% {
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        13%,
        21% {
          transform: scale(1.03);
          animation-timing-function: var(--ease-out);
        }
        23% {
          transform: scale(1.005, 0.985);
          animation-timing-function: var(--ease-out);
        }
        26.5%,
        100% {
          transform: scale(1);
        }
      }

      /* Elevation dies on contact in 96ms rather than drifting off over 176ms:
         the shadow is a statement about distance, and the distance is gone. */
      @keyframes elevate {
        0%,
        8% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        13%,
        21% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        24%,
        100% {
          opacity: 0;
        }
      }

      @keyframes drag-row {
        0%,
        15% {
          transform: translateY(0);
          animation-timing-function: var(--ease-out);
        }
        21%,
        100% {
          transform: translateY(42px);
        }
      }

      /* The gap opens 32ms before the row starts moving. Leading, not trailing:
         the list has to look like it already knows where the row is going. */
      @keyframes gap-open {
        0%,
        14% {
          transform: translateY(0);
          animation-timing-function: var(--ease-out);
        }
        20.5%,
        100% {
          transform: translateY(-42px);
        }
      }

      @keyframes list-cycle {
        0% {
          opacity: 0;
        }
        4%,
        88% {
          opacity: 1;
        }
        96%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        .list,
        .r1,
        .r2,
        .r1 .card {
          animation: none;
          opacity: 1;
          transform: none;
        }

        /* No travel, no reorder — the elevation still fades in and out, so the
           row is still visibly picked up and put back down. */
        .r1 .shadow,
        .r1 .sheet {
          animation: hold-elevate 3.2s ease infinite;
        }

        @keyframes hold-elevate {
          0%,
          10% {
            opacity: 0;
          }
          22%,
          60% {
            opacity: 1;
          }
          75%,
          100% {
            opacity: 0;
          }
        }

        .pointer {
          animation: hold-press 3.2s ease infinite;
          transform: none;
        }

        @keyframes hold-press {
          0%,
          6% {
            opacity: 0;
          }
          18%,
          62% {
            opacity: 1;
          }
          78%,
          100% {
            opacity: 0;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="list"
      role="img"
      aria-label="A list row lifted by its handle and settling into a new position"
    >
      <div class="row r1">
        <div class="card">
          <span class="shadow"></span>
          <span class="sheet"></span>
          <span class="grip"></span>
          <span class="bar"></span>
          <span class="pointer"></span>
        </div>
      </div>
      <div class="row r2">
        <div class="card">
          <span class="grip"></span>
          <span class="bar"></span>
        </div>
      </div>
      <div class="row r3">
        <div class="card">
          <span class="grip"></span>
          <span class="bar"></span>
        </div>
      </div>
    </div>
  </body>
</html>

The variant family

Same idea, one axis moved. Compare them side by side before you commit.