Back to the library

Reorder, flick and overshoot

Variant: Flick and overshoot

A flicked row rather than a dragged one. The pointer leaves before the row has gone anywhere, and momentum carries it 6px past the slot and leans it 1.6 degrees before it decays back; the neighbour, only displaced, overshoots half as far. The 432ms settle is the one place in this set worth going past 300ms — the response you actually judge it by lands in 144ms.

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 — flick and overshoot</title>
    <style>
      :root {
        --ivory: #faf9f5;
        --slate: #141413;
        --clay: #d97757;
        --oat: #e3dacc;
        --paper: #ffffff;
        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        --ease-sine: cubic-bezier(0.4, 0, 0.6, 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);
      }

      .list {
        position: relative;
        width: 190px;
        height: 118px;
        animation: list-cycle 3.2s linear infinite;
      }

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

      /* The thrown row passes over its neighbour, so it holds the higher stacking
         context for the whole cycle — a flicked object goes above, never through. */
      .r1 {
        top: 0;
        z-index: 2;
        animation: throw 3.2s infinite;
      }
      .r2 {
        top: 42px;
        animation: displaced 3.2s infinite;
      }
      .r3 {
        top: 84px;
      }

      .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: lean 3.2s infinite;
      }

      .shadow {
        position: absolute;
        inset: -1px;
        border-radius: 9px;
        box-shadow: 0 8px 18px -8px rgba(20, 20, 19, 0.34);
        opacity: 0;
        animation: airborne 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: flick-press 3.2s infinite;
      }

      /* The pointer leaves at 9.5% — before the row has gone anywhere. Everything
         after that is the row travelling on the momentum it was given, which is
         the only reason a 432ms settle is allowed on a list this small: the
         response you judge it by happened in the first 144ms. */
      @keyframes flick-press {
        0%,
        5% {
          opacity: 0;
          transform: scale(1.35);
          animation-timing-function: var(--ease-out);
        }
        7.5%,
        9.5% {
          opacity: 1;
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        12%,
        100% {
          opacity: 0;
          transform: scale(1.15);
        }
      }

      @keyframes throw {
        0%,
        9.5% {
          transform: translateY(0);
          animation-timing-function: var(--ease-out);
        }
        14% {
          transform: translateY(48px);
          animation-timing-function: var(--ease-sine);
        }
        17.5% {
          transform: translateY(40px);
          animation-timing-function: var(--ease-sine);
        }
        20.5%,
        100% {
          transform: translateY(42px);
        }
      }

      /* 6px of overshoot on the thrown row against 3px on the neighbour. The
         neighbour was displaced, not flicked, so it carries half the energy. */
      @keyframes displaced {
        0%,
        9% {
          transform: translateY(0);
          animation-timing-function: var(--ease-out);
        }
        13.5% {
          transform: translateY(-45px);
          animation-timing-function: var(--ease-sine);
        }
        16.5% {
          transform: translateY(-41.5px);
          animation-timing-function: var(--ease-sine);
        }
        19%,
        100% {
          transform: translateY(-42px);
        }
      }

      @keyframes lean {
        0%,
        7% {
          transform: scale(1) rotate(0deg);
          animation-timing-function: var(--ease-out);
        }
        9.5% {
          transform: scale(1.02) rotate(0deg);
          animation-timing-function: var(--ease-out);
        }
        14% {
          transform: scale(1.02) rotate(1.6deg);
          animation-timing-function: var(--ease-sine);
        }
        20.5%,
        100% {
          transform: scale(1) rotate(0deg);
        }
      }

      @keyframes airborne {
        0%,
        8% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        12%,
        15% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        21%,
        100% {
          opacity: 0;
        }
      }

      @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;
        }

        /* Overshoot is the first thing to go under reduced motion — it is pure
           vestibular decoration. The grab and release survive as a fade. */
        .shadow {
          animation: hold-airborne 3.2s ease infinite;
        }

        @keyframes hold-airborne {
          0%,
          10% {
            opacity: 0;
          }
          22%,
          52% {
            opacity: 1;
          }
          68%,
          100% {
            opacity: 0;
          }
        }

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

        @keyframes hold-press {
          0%,
          6% {
            opacity: 0;
          }
          18%,
          44% {
            opacity: 1;
          }
          58%,
          100% {
            opacity: 0;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="list"
      role="img"
      aria-label="A list row flicked into the next slot, overshooting and settling back"
    >
      <div class="row r1">
        <div class="card">
          <span class="shadow"></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.