Back to the library

Reorder, flat swap

Variant: Flat swap

The same reorder with no elevation at all. Two rows still have to share a band on the way past, and with nothing lifted above the plane the crossing gets resolved sideways: the travelling row parts 10px and drops to half opacity while it moves. A slot change costs 256ms and nothing has to rise and fall, which is why the loop does two of them back to back.

controllistgesture

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 — flat swap</title>
    <style>
      :root {
        --ivory: #faf9f5;
        --slate: #141413;
        --clay: #d97757;
        --oat: #e3dacc;
        --paper: #ffffff;
        --ease-in-out: cubic-bezier(0.77, 0, 0.175, 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);
      }

      /* Two swaps in one 3.2s loop, then a dissolve to reset. Doing it twice is
         the point: with no lift to raise and lower, a slot change costs 256ms
         and can repeat straight away — nothing leaves the plane of the list. */
      .list {
        position: relative;
        width: 190px;
        height: 118px;
        animation: list-cycle 3.2s linear infinite;
      }

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

      /* Stacking is declared, not inherited from source order. Nothing is lifted
         here, so the crossing has to resolve somewhere: the travelling row goes
         behind the rows it trades with, every time, in every browser. Leaving
         that to DOM order works right up until someone reorders the markup. */
      .r1 {
        top: 0;
        z-index: 1;
        animation: fall-two 3.2s infinite;
      }
      .r2 {
        top: 42px;
        z-index: 2;
        animation: rise-first 3.2s infinite;
      }
      .r3 {
        top: 84px;
        z-index: 2;
        animation: rise-second 3.2s infinite;
      }

      .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: part-right 3.2s infinite;
      }
      .r2 .card {
        animation: part-left-first 3.2s infinite;
      }
      .r3 .card {
        animation: part-left-second 3.2s infinite;
      }

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

      @keyframes press {
        0%,
        5% {
          opacity: 0;
          transform: scale(1.35);
          animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
        }
        8%,
        38% {
          opacity: 1;
          transform: scale(1);
        }
        44%,
        100% {
          opacity: 0;
          transform: scale(1);
        }
      }

      /* The neighbour leaves 64ms before the dragged row starts and lands 112ms
         before it does, so the two are only in the same band briefly. Ease-in-out
         because both ends are on-screen positions — nothing is entering here. */
      @keyframes fall-two {
        0%,
        10% {
          transform: translateY(0);
          animation-timing-function: var(--ease-in-out);
        }
        18%,
        30% {
          transform: translateY(42px);
          animation-timing-function: var(--ease-in-out);
        }
        38%,
        100% {
          transform: translateY(84px);
        }
      }

      @keyframes rise-first {
        0%,
        8% {
          transform: translateY(0);
          animation-timing-function: var(--ease-in-out);
        }
        14.5%,
        100% {
          transform: translateY(-42px);
        }
      }

      @keyframes rise-second {
        0%,
        28% {
          transform: translateY(0);
          animation-timing-function: var(--ease-in-out);
        }
        34.5%,
        100% {
          transform: translateY(-42px);
        }
      }

      /* Two rows swapping have to share a band on the way past. With no lift to
         put one above the other, the crossing is resolved on the same plane: the
         travelling row parts 10px sideways and drops to half opacity while it
         moves, so it reads as passing rather than colliding. */
      @keyframes part-right {
        0%,
        10% {
          transform: translateX(0);
          opacity: 1;
          animation-timing-function: var(--ease-sine);
        }
        14% {
          transform: translateX(10px);
          opacity: 0.5;
          animation-timing-function: var(--ease-sine);
        }
        18%,
        30% {
          transform: translateX(0);
          opacity: 1;
          animation-timing-function: var(--ease-sine);
        }
        34% {
          transform: translateX(10px);
          opacity: 0.5;
          animation-timing-function: var(--ease-sine);
        }
        38%,
        100% {
          transform: translateX(0);
          opacity: 1;
        }
      }

      @keyframes part-left-first {
        0%,
        8% {
          transform: translateX(0);
          opacity: 1;
          animation-timing-function: var(--ease-sine);
        }
        11.5% {
          transform: translateX(-10px);
          opacity: 0.75;
          animation-timing-function: var(--ease-sine);
        }
        14.5%,
        100% {
          transform: translateX(0);
          opacity: 1;
        }
      }

      @keyframes part-left-second {
        0%,
        28% {
          transform: translateX(0);
          opacity: 1;
          animation-timing-function: var(--ease-sine);
        }
        31.5% {
          transform: translateX(-10px);
          opacity: 0.75;
          animation-timing-function: var(--ease-sine);
        }
        34.5%,
        100% {
          transform: translateX(0);
          opacity: 1;
        }
      }

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

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

        .r1 .card,
        .r2 .card,
        .r3 .card {
          animation: none;
          opacity: 1;
          transform: none;
        }

        /* Travel gone, so the reorder is told in colour instead: the clay handle
           marking the held row hands off to the row it traded with. Same fact,
           no displacement. */
        .r1 .grip {
          animation: grip-cool 3.2s ease infinite;
        }

        .r2 .grip {
          animation: grip-warm 3.2s ease infinite;
        }

        @keyframes grip-cool {
          0%,
          18% {
            color: var(--clay);
          }
          34%,
          62% {
            color: rgba(20, 20, 19, 0.34);
          }
          82%,
          100% {
            color: var(--clay);
          }
        }

        @keyframes grip-warm {
          0%,
          18% {
            color: rgba(20, 20, 19, 0.34);
          }
          34%,
          62% {
            color: var(--clay);
          }
          82%,
          100% {
            color: rgba(20, 20, 19, 0.34);
          }
        }

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

        @keyframes hold-press {
          0%,
          6% {
            opacity: 0;
          }
          18%,
          58% {
            opacity: 1;
          }
          74%,
          100% {
            opacity: 0;
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="list" role="img" aria-label="Two list rows exchanging places on a flat plane">
      <div class="row r1">
        <div class="card">
          <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.