Back to the library

Line draw — signed by hand

Variant: Signed by hand

Three strokes with the pen off the paper between them, and the lifts are the whole piece: 638ms of a 5.8s loop spent drawing nothing is the only thing separating handwriting from a plotter tracing a font. The strokes are deliberately not one speed — the body runs at 163 units a second because it is shaped, the cross-bar at 322 because a t-cross is a flick rather than a stroke, and the flourish at 235. Every one of them eases in as well as out, which is the one place in this library where an ease-in half is correct: a hand leaves rest and returns to rest inside each stroke, and the middle stays near constant because nobody coasts to a crawl mid-letter. The printed rule takes the ink’s colour 290ms after the last stroke lands, which is what turns a drawing into a state — the form is signed, not merely written on.

svgline-drawingentranceloop

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>Line draw — signed by hand</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 — 5.8s: 2.2s of body, a 319ms lift, a 232ms cross, another
           319ms lift, an 870ms flourish, then the form sits signed. The two
           lifts are 638ms of the loop spent drawing nothing at all, and they
           are the only reason this reads as a hand rather than a plotter. */
        --cycle: 5.8s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        /* A gentle symmetric S. The pen leaves rest and returns to rest inside
           every stroke, so this is the one place in the library where an
           ease-in half is correct — but the middle stays near constant, because
           a hand does not coast to a crawl in the middle of a letter. */
        --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);
      }

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

      /* The printed form is the substrate: it never draws and never resets, so
         the loop reads as the same document being signed again. */
      .rule {
        stroke: var(--oat);
        stroke-width: 2;
      }

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

      .ink path {
        fill: none;
        stroke: var(--slate);
        stroke-width: 3;
        stroke-linecap: round;
        stroke-linejoin: round;
        /* Offsets run 103 to 0, not 100 to 0: at exactly 100 the dash's round
           cap still paints, so a stroke waiting its turn shows as an ink dot
           where it is about to begin. Three units parks the cap clear of the
           path, and the 200 gap does the same at the far end. */
        stroke-dasharray: 100 200;
      }

      /* pathLength="100" normalises three strokes of very different real
         lengths onto one 0-100 scale, so each window below is a share of the
         loop rather than a number tuned per path. The speeds that fall out are
         163, 322 and 235 units a second — deliberately not equal. */
      .stroke-a {
        animation: stroke-a var(--t) infinite;
      }

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

      .stroke-c {
        animation: stroke-c var(--t) infinite;
      }

      .signed {
        stroke: var(--clay);
        stroke-width: 2;
        animation: countersign var(--t) infinite;
      }

      .mark {
        animation: mark-hold var(--t) infinite;
      }

      @keyframes stroke-a {
        0%,
        2% {
          stroke-dashoffset: 103;
          animation-timing-function: var(--ease-accent);
        }
        40%,
        96% {
          stroke-dashoffset: 0;
        }
        100% {
          stroke-dashoffset: 103;
        }
      }

      /* The cross-bar covers a fifth of the body's distance at twice its speed.
         A t-cross is a flick, not a stroke — matched to the body's pace it
         stops reading as handwriting and starts reading as a printed rule. */
      @keyframes stroke-b {
        0%,
        45.5% {
          stroke-dashoffset: 103;
          animation-timing-function: var(--ease-accent);
        }
        49.5%,
        96% {
          stroke-dashoffset: 0;
        }
        100% {
          stroke-dashoffset: 103;
        }
      }

      @keyframes stroke-c {
        0%,
        55% {
          stroke-dashoffset: 103;
          animation-timing-function: var(--ease-accent);
        }
        70%,
        96% {
          stroke-dashoffset: 0;
        }
        100% {
          stroke-dashoffset: 103;
        }
      }

      /* The rule takes the signature's colour 290ms after the last stroke
         lands, which is what turns a drawing into a state: the form is signed,
         not merely written on. */
      @keyframes countersign {
        0%,
        70% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        75%,
        100% {
          opacity: 1;
        }
      }

      /* Everything the hand put there is dark by 96%, which is the only reason
         three dashoffsets can rewind to 103 in the last 232ms unseen. */
      @keyframes mark-hold {
        0%,
        90% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        96%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The draw survives at 1.7x — a signature that appears fully formed is
           not a slower signature, it is a stamp — and every clock scales by the
           same factor, so the lifts keep their share of the loop. */
        .mark,
        .stroke-a,
        .stroke-b,
        .stroke-c,
        .signed {
          animation-duration: calc(var(--t) * 1.7);
        }
      }
    </style>
  </head>
  <body>
    <svg
      class="sig"
      viewBox="0 0 260 132"
      role="img"
      aria-label="A signature writing itself onto a form in three strokes, with the pen lifting between them, after which the signature line takes the ink's colour"
    >
      <line class="rule" x1="16" y1="116" x2="244" y2="116" />
      <text class="caption" x="16" y="130">SIGNATURE</text>

      <g class="mark">
        <line class="signed" x1="16" y1="116" x2="244" y2="116" />

        <g class="ink">
          <path
            class="stroke-a"
            pathLength="100"
            d="M 26 88 C 30 58, 42 26, 56 26 C 68 26, 62 50, 52 70 C 44 86, 50 96, 62 88 C 72 81, 80 64, 88 54 C 94 46, 101 49, 99 60 C 97 71, 92 82, 97 86 C 102 90, 112 82, 120 68 C 126 58, 133 52, 137 58 C 141 64, 134 78, 137 84 C 140 90, 150 86, 162 72"
          />
          <path class="stroke-b" pathLength="100" d="M 84 44 C 108 38, 134 36, 158 41" />
          <path
            class="stroke-c"
            pathLength="100"
            d="M 22 104 C 62 96, 120 94, 168 100 C 196 103, 214 96, 220 84"
          />
        </g>
      </g>
    </svg>
  </body>
</html>

The variant family

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