Stitch — running seam
Variant: Running seam
Two panels laced together by a stitch that lands in six discrete beats — steps(6), never a glide, because a needle has either passed through the cloth or it has not, and a dashed edge that eases on reads as a dotted rule being printed. The round cap parks at the entry point one full beat before the first stitch: the needle arriving is part of the work. When the last stitch lands the lacing takes up 92ms of slack — the same wind-up its sibling knot makes — and cinches on ease-in-out: the panels close over half their gap and the zigzag steepens about the seam’s own centre, one scale on one clock, so the thread can never detach from the cloth for a frame, and the thread takes clay as it comes tight, because until something is pulled a seam is just thread lying across a gap. Reach for it when two things become one: accounts merged, records linked, a document attached to the thing it belongs to.
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 — running seam</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.6s: a rest, six stitches at 360ms a beat, a 92ms wind-up,
a 276ms cinch, then the seam holds joined for over a second before
the reset. */
--cycle: 4.6s;
--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);
}
* {
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;
}
.caption {
fill: var(--_muted);
font-family: inherit;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.16em;
}
/* The lacing draws by dashoffset, but on steps(6) rather than a glide: a
needle has either passed through the cloth or it has not, and a dashed
edge that eases on reads as a dotted rule being printed, not sewing. */
.thread {
fill: none;
stroke: var(--slate);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
/* 103, not 100, while resting: at exactly 100 the round cap still
paints, so the parked cap is granted its own beat below instead of
leaking through the rest window. */
stroke-dasharray: 100 200;
animation:
lace var(--t) infinite,
ink var(--t) infinite;
}
/* The cinch scales the whole lacing about the seam's centre line, so the
zigzag steepens exactly as fast as the gap closes — a second timing
would let the thread detach from the cloth for a frame. */
.lacing {
animation: cinch var(--t) infinite;
}
@keyframes lace {
/* Rest: nothing, cap parked clear of the path. */
0%,
8% {
stroke-dashoffset: 103;
}
/* The cap slides to the entry point and waits one beat — the needle
arriving is part of the work, and the pause is what makes the first
stitch a decision rather than the start of a wipe. */
9% {
stroke-dashoffset: 100;
animation-timing-function: steps(6, end);
}
/* Six stitches, one leg per step, landing whole on each beat. */
56%,
93% {
stroke-dashoffset: 0;
animation-timing-function: steps(1, end);
}
94%,
100% {
stroke-dashoffset: 103;
}
}
/* Anticipation, then the pull — the same physics as the knot in this
family: the lacing widens 3% and the panels back off 1px for 92ms of
taken-up slack, then the collapse is ease-in-out, because a yank is
fastest in its middle. Lacing and panels share every window and every
curve, which is what keeps the thread welded to the cloth. */
@keyframes cinch {
0%,
60% {
transform: scaleX(1);
animation-timing-function: var(--ease-in-out);
}
62% {
transform: scaleX(1.03);
animation-timing-function: var(--ease-in-out);
}
68%,
93% {
transform: scaleX(0.42);
animation-timing-function: steps(1, end);
}
94%,
100% {
transform: scaleX(1);
}
}
/* Thread takes clay as it comes tight: until something is pulled, a
seam is only thread lying across a gap. */
@keyframes ink {
0%,
62% {
stroke: var(--slate);
animation-timing-function: var(--ease-out);
}
68%,
93% {
stroke: var(--clay-d);
animation-timing-function: steps(1, end);
}
94%,
100% {
stroke: var(--slate);
}
}
@keyframes panel-l {
0%,
60% {
transform: translateX(0);
animation-timing-function: var(--ease-in-out);
}
62% {
transform: translateX(-1px);
animation-timing-function: var(--ease-in-out);
}
68%,
93% {
transform: translateX(7px);
animation-timing-function: steps(1, end);
}
94%,
100% {
transform: translateX(0);
}
}
@keyframes panel-r {
0%,
60% {
transform: translateX(0);
animation-timing-function: var(--ease-in-out);
}
62% {
transform: translateX(1px);
animation-timing-function: var(--ease-in-out);
}
68%,
93% {
transform: translateX(-7px);
animation-timing-function: steps(1, end);
}
94%,
100% {
transform: translateX(0);
}
}
/* Everything rewinds — offset, cinch, panels — inside this dark window,
so the loop reads as the same two panels being sewn again. */
@keyframes scene-reset {
0%,
88% {
opacity: 1;
}
92%,
97% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
/* The stitching survives at 1.8x — steps() means nothing sweeps, each
beat is a still frame replacing the last — and every clock scales by
the same factor so the cinch keeps its place in the loop. */
.thread,
.lacing,
.panel-l,
.panel-r,
.scene {
animation-duration: calc(var(--t) * 1.8);
}
}
</style>
</head>
<body>
<svg
class="seam"
viewBox="0 0 260 148"
role="img"
aria-label="Two panels laced together by a running stitch that lands one stitch per beat, then the lacing cinches tight, the gap closes, and the thread takes the accent colour"
>
<g class="scene">
<rect class="panel panel-l" x="30" y="24" width="88" height="94" />
<rect class="panel panel-r" x="142" y="24" width="88" height="94" />
<g class="lacing" style="transform-origin: 130px 71px">
<path
class="thread"
pathLength="100"
d="M 118 32 L 142 46 L 118 60 L 142 74 L 118 88 L 142 102 L 118 116"
/>
</g>
<text class="caption" x="30" y="140">SEAM</text>
</g>
</svg>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.