Back to the library

Stitch — unpick

Variant: Unpick

Undo with the right order of operations: the seam was sewn downward, so it comes out upward — the last stitch in is the first stitch out, because undo is a stack and an animation that removes work front-to-back is quietly lying about how history works. Each stitch leaves in 192ms on ease-out with a 16-degree turn, pulled rather than faded, and the panels hold perfectly still until the final stitch is gone — held things do not drift while something still holds them. Then they spring 1px past their resting gap and settle, the stored tension of the seam released in the cheapest possible spring. Use it for unlink, detach, and revert moments where the user needs to see the join actually come apart instead of the joined thing simply vanishing.

svgline-drawingcontrolloop

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>Stitch — unpick</title>
    <style>
      :root {
        /* Neutrals — fixed, never remixed. */
        --ivory: #faf9f5;
        --paper: #ffffff;
        --oat: #e3dacc;
        --slate: #141413;

        /* Knobs — the remix API. A knob exists iff declared here and used below. */
        --hue: 38.8;
        --lift: 0;
        --chroma: 1;
        --round: 1;
        --speed: 1;

        /* Accents — derived from the knobs with the family's offsets baked in,
           so one hue roll moves them together. Defaults reproduce the original hex. */
        --clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
        --clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
        --olive: oklch(
          calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
        );
        --rust: oklch(
          calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
        );

        --_muted: color-mix(in srgb, var(--slate) 42%, transparent);

        /* Clock — 4.8s: a rest with the seam intact, six stitches out at a
           288ms cadence, the panels released, then a long hold apart. */
        --cycle: 4.8s;
        --t: calc(var(--cycle) / var(--speed));

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

      .seam {
        width: min(280px, 86vw);
        height: auto;
        display: block;
      }

      .scene {
        animation: scene-reset var(--t) infinite;
      }

      .panel {
        fill: var(--paper);
        stroke: var(--oat);
        stroke-width: 2;
        rx: calc(6px * var(--round));
      }

      .panel-l {
        animation: panel-l var(--t) infinite;
      }

      .panel-r {
        animation: panel-r var(--t) infinite;
      }

      .st {
        fill: none;
        stroke: var(--slate);
        stroke-width: 3;
        stroke-linecap: round;
        /* Each stitch flicks up and out on ease-out — pulled, so it leaves
           rest instantly — with a small rotation, because a freed loop of
           thread turns as it goes. Opacity holds through the first 40% of the
           flick so the pull reads before the fade does any work. 192ms:
           removal is not an entrance. */
        transform-box: fill-box;
        transform-origin: 50% 50%;
      }

      .caption {
        fill: var(--_muted);
        font-family: inherit;
        font-size: 11px;
        font-weight: 600;
        letter-spacing: 0.16em;
        animation: caption var(--t) infinite;
      }

      .st-f {
        animation: st-f var(--t) infinite;
      }
      .st-e {
        animation: st-e var(--t) infinite;
      }
      .st-d {
        animation: st-d var(--t) infinite;
      }
      .st-c {
        animation: st-c var(--t) infinite;
      }
      .st-b {
        animation: st-b var(--t) infinite;
      }
      .st-a {
        animation: st-a var(--t) infinite;
      }

      /* Bottom stitch first, top stitch last: the seam was sewn downward, so
         it comes out upward — undo is a stack, and the last stitch in is the
         first stitch out. */
      @keyframes st-f {
        0%,
        12% {
          opacity: 1;
          transform: translateY(0) rotate(0deg);
          animation-timing-function: var(--ease-out);
        }
        13.6% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        16%,
        93% {
          opacity: 0;
          transform: translateY(-7px) rotate(16deg);
          animation-timing-function: steps(1, end);
        }
        94%,
        100% {
          opacity: 1;
          transform: translateY(0) rotate(0deg);
        }
      }

      @keyframes st-e {
        0%,
        18% {
          opacity: 1;
          transform: translateY(0) rotate(0deg);
          animation-timing-function: var(--ease-out);
        }
        19.6% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        22%,
        93% {
          opacity: 0;
          transform: translateY(-7px) rotate(-16deg);
          animation-timing-function: steps(1, end);
        }
        94%,
        100% {
          opacity: 1;
          transform: translateY(0) rotate(0deg);
        }
      }

      @keyframes st-d {
        0%,
        24% {
          opacity: 1;
          transform: translateY(0) rotate(0deg);
          animation-timing-function: var(--ease-out);
        }
        25.6% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        28%,
        93% {
          opacity: 0;
          transform: translateY(-7px) rotate(16deg);
          animation-timing-function: steps(1, end);
        }
        94%,
        100% {
          opacity: 1;
          transform: translateY(0) rotate(0deg);
        }
      }

      @keyframes st-c {
        0%,
        30% {
          opacity: 1;
          transform: translateY(0) rotate(0deg);
          animation-timing-function: var(--ease-out);
        }
        31.6% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        34%,
        93% {
          opacity: 0;
          transform: translateY(-7px) rotate(-16deg);
          animation-timing-function: steps(1, end);
        }
        94%,
        100% {
          opacity: 1;
          transform: translateY(0) rotate(0deg);
        }
      }

      @keyframes st-b {
        0%,
        36% {
          opacity: 1;
          transform: translateY(0) rotate(0deg);
          animation-timing-function: var(--ease-out);
        }
        37.6% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        40%,
        93% {
          opacity: 0;
          transform: translateY(-7px) rotate(16deg);
          animation-timing-function: steps(1, end);
        }
        94%,
        100% {
          opacity: 1;
          transform: translateY(0) rotate(0deg);
        }
      }

      @keyframes st-a {
        0%,
        42% {
          opacity: 1;
          transform: translateY(0) rotate(0deg);
          animation-timing-function: var(--ease-out);
        }
        43.6% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        46%,
        93% {
          opacity: 0;
          transform: translateY(-7px) rotate(-16deg);
          animation-timing-function: steps(1, end);
        }
        94%,
        100% {
          opacity: 1;
          transform: translateY(0) rotate(0deg);
        }
      }

      /* The panels move only after the last stitch is out — held things do not
         drift while something still holds them — and they overshoot by 1px
         before settling, because a seam under tension stores a little spring. */
      @keyframes panel-l {
        0%,
        48% {
          transform: translateX(0);
          animation-timing-function: var(--ease-out);
        }
        53% {
          transform: translateX(-7px);
          animation-timing-function: var(--ease-out);
        }
        57%,
        93% {
          transform: translateX(-6px);
          animation-timing-function: steps(1, end);
        }
        94%,
        100% {
          transform: translateX(0);
        }
      }

      @keyframes panel-r {
        0%,
        48% {
          transform: translateX(0);
          animation-timing-function: var(--ease-out);
        }
        53% {
          transform: translateX(7px);
          animation-timing-function: var(--ease-out);
        }
        57%,
        93% {
          transform: translateX(6px);
          animation-timing-function: steps(1, end);
        }
        94%,
        100% {
          transform: translateX(0);
        }
      }

      /* The label waits for the release: the state is announced after the
         panels are actually free, not while the work is still coming out. */
      @keyframes caption {
        0%,
        56% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        63%,
        93% {
          opacity: 1;
          animation-timing-function: steps(1, end);
        }
        94%,
        100% {
          opacity: 0;
        }
      }

      /* Stitches and panels rewind inside this dark window, so the loop reads
         as another seam being unpicked rather than this one re-sewing. */
      @keyframes scene-reset {
        0%,
        88% {
          opacity: 1;
        }
        93%,
        97% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        100% {
          opacity: 1;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The unpick survives at 1.8x — each stitch is either in or out, and
           the flick is small — with every clock scaled together so the
           release keeps its place after the last stitch. */
        .scene,
        .st-a,
        .st-b,
        .st-c,
        .st-d,
        .st-e,
        .st-f,
        .panel-l,
        .panel-r,
        .caption {
          animation-duration: calc(var(--t) * 1.8);
        }
      }
    </style>
  </head>
  <body>
    <svg
      class="seam"
      viewBox="0 0 260 148"
      role="img"
      aria-label="A stitched seam between two panels comes undone one stitch at a time from the bottom up, each stitch flicking away, and when the last is out the two panels spring gently apart"
    >
      <g class="scene">
        <rect class="panel panel-l" x="38" y="24" width="88" height="94" />
        <rect class="panel panel-r" x="134" y="24" width="88" height="94" />

        <g>
          <line class="st st-a" x1="126" y1="32" x2="134" y2="46" />
          <line class="st st-b" x1="134" y1="46" x2="126" y2="60" />
          <line class="st st-c" x1="126" y1="60" x2="134" y2="74" />
          <line class="st st-d" x1="134" y1="74" x2="126" y2="88" />
          <line class="st st-e" x1="126" y1="88" x2="134" y2="102" />
          <line class="st st-f" x1="134" y1="102" x2="126" y2="116" />
        </g>

        <text class="caption" x="38" y="140">UNPICKED</text>
      </g>
    </svg>
  </body>
</html>

The variant family

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