Back to the library

Stitch — knot cinch

Variant: Knot cinch

The commit gesture: a thread lays in with a loose loop at its middle — three moves with lifts between them, which is why it reads as a hand and not a plotter — rests a beat, then cinches. The loop stretches 10% wider for 126ms before it collapses, the same wind-up a hand makes taking up slack before a yank, and the collapse is ease-in-out because a pull is fastest in its middle. The knot grows out of the collapsing loop rather than appearing from nothing, overshoots to 1.15 and settles — the one bounce in the piece, earned by tension — while the whole thread drops 1.5px and returns, follow-through travelling down the line. TIED OFF lands only after the knot exists: a state is announced once it is true. Reach for it at the end of a hold-to-confirm, a form finalised, anything where the user should feel the decision seat.

svgline-drawingsuccesscontrolloop

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 — knot cinch</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;
        --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.2s: the thread lays in three moves, rests a beat, stretches
           for 126ms, cinches in 252ms, and then the knot just sits there. The
           rest before the pull is what makes the pull a decision. */
        --cycle: 4.2s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
        /* The hand curve: the pen leaves rest and returns to rest inside every
           move, but the middle stays near constant — a hand does not coast to
           a crawl in the middle of a gesture. */
        --ease-accent: cubic-bezier(0.4, 0.08, 0.6, 0.92);
      }

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

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

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

      .thread {
        animation: twang var(--t) infinite;
      }

      .thread path,
      .thread line {
        fill: none;
        stroke: var(--slate);
        stroke-width: 3;
        stroke-linecap: round;
        /* 103, not 100, at rest: at exactly 100 the round cap still paints,
           so a stroke waiting its turn would show as a dot where it begins. */
        stroke-dasharray: 100 200;
      }

      .seg-a {
        animation: seg-a var(--t) infinite;
      }

      .loop-draw {
        animation: loop-draw var(--t) infinite;
      }

      .seg-b {
        animation: seg-b var(--t) infinite;
      }

      .loop-g {
        animation: cinch var(--t) infinite;
      }

      .knot-dot {
        fill: var(--clay-d);
        animation: knot-pop var(--t) infinite;
      }

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

      /* Three moves with 147ms lifts between them: lead-in, loop, tail. The
         lifts are why this reads as a hand making a knot, not a plotter — a
         plotter never leaves the paper. */
      @keyframes seg-a {
        0%,
        6% {
          stroke-dashoffset: 103;
          animation-timing-function: var(--ease-accent);
        }
        15%,
        93% {
          stroke-dashoffset: 0;
          animation-timing-function: steps(1, end);
        }
        94%,
        100% {
          stroke-dashoffset: 103;
        }
      }

      @keyframes loop-draw {
        0%,
        18.5% {
          stroke-dashoffset: 103;
          animation-timing-function: var(--ease-accent);
        }
        32%,
        93% {
          stroke-dashoffset: 0;
          animation-timing-function: steps(1, end);
        }
        94%,
        100% {
          stroke-dashoffset: 103;
        }
      }

      @keyframes seg-b {
        0%,
        35.5% {
          stroke-dashoffset: 103;
          animation-timing-function: var(--ease-accent);
        }
        43%,
        93% {
          stroke-dashoffset: 0;
          animation-timing-function: steps(1, end);
        }
        94%,
        100% {
          stroke-dashoffset: 103;
        }
      }

      /* Anticipation, then the pull. The loop stretches 10% wider and 4%
         flatter for 126ms before it collapses — a wind-up in the opposite
         direction, the same beat a hand takes up slack before it yanks. The
         collapse itself is ease-in-out: a yank is fastest in its middle. */
      @keyframes cinch {
        0%,
        50% {
          transform: scale(1, 1);
          opacity: 1;
          animation-timing-function: var(--ease-in-out);
        }
        53% {
          transform: scale(1.1, 0.96);
          opacity: 1;
          animation-timing-function: var(--ease-in-out);
        }
        58.5% {
          opacity: 1;
        }
        59%,
        93% {
          transform: scale(0.06, 0.06);
          opacity: 0;
          animation-timing-function: steps(1, end);
        }
        94%,
        100% {
          transform: scale(1, 1);
          opacity: 1;
        }
      }

      /* The knot grows out of the collapsing loop — never from nothing — and
         overshoots to 1.15 before settling: the one place in the piece with
         bounce, because a cinched knot is a thing under tension coming to
         rest. */
      @keyframes knot-pop {
        0%,
        55% {
          transform: scale(0);
          animation-timing-function: var(--ease-out);
        }
        59% {
          transform: scale(1.15);
          animation-timing-function: var(--ease-out);
        }
        62%,
        93% {
          transform: scale(1);
          animation-timing-function: steps(1, end);
        }
        94%,
        100% {
          transform: scale(0);
        }
      }

      /* The whole thread drops 1.5px and returns as the knot seats — tension
         released travels down the line. Blink and you miss it; that is the
         right size for follow-through. */
      @keyframes twang {
        0%,
        55% {
          transform: translateY(0);
          animation-timing-function: var(--ease-out);
        }
        58% {
          transform: translateY(1.5px);
          animation-timing-function: var(--ease-out);
        }
        61%,
        100% {
          transform: translateY(0);
        }
      }

      /* The label waits for the knot: a state is announced after it exists. */
      @keyframes caption {
        0%,
        64% {
          opacity: 0;
          transform: translateY(3px);
          animation-timing-function: var(--ease-out);
        }
        71%,
        93% {
          opacity: 1;
          transform: translateY(0);
          animation-timing-function: steps(1, end);
        }
        94%,
        100% {
          opacity: 0;
          transform: translateY(3px);
        }
      }

      /* Every offset and transform rewinds inside this dark window, so the
         loop reads as another length of thread being tied. */
      @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 tie survives at 1.8x — a knot that appears fully formed is not a
           slower knot, it is a bead — and every clock scales by the same
           factor so the twang stays welded to the cinch. */
        .scene,
        .thread,
        .seg-a,
        .loop-draw,
        .seg-b,
        .loop-g,
        .knot-dot,
        .caption {
          animation-duration: calc(var(--t) * 1.8);
        }
      }
    </style>
  </head>
  <body>
    <svg
      class="knot"
      viewBox="0 0 260 120"
      role="img"
      aria-label="A thread draws in with a loose loop at its middle, the loop stretches for a beat, then cinches down into a small knot that seats with a faint twang, and the label TIED OFF appears"
    >
      <g class="scene">
        <g class="thread">
          <line class="seg-a" pathLength="100" x1="24" y1="64" x2="126" y2="64" />
          <g class="loop-g" style="transform-origin: 130px 62px">
            <path
              class="loop-draw"
              pathLength="100"
              d="M 130 62 A 20 20 0 1 1 130 22 A 20 20 0 1 1 130 62"
            />
          </g>
          <line class="seg-b" pathLength="100" x1="134" y1="64" x2="236" y2="64" />
          <circle class="knot-dot" style="transform-origin: 130px 63px" cx="130" cy="63" r="4.5" />
        </g>
        <text class="caption" x="24" y="104">TIED OFF</text>
      </g>
    </svg>
  </body>
</html>

The variant family

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