Back to the library

Patch stamp save, linear pass

Variant: Linear pass

The same hand-cut patch landing in its slot, but the drop and the stitch run on a constant-rate curve. Reach for it when the confirmation sits next to other machine-paced indicators — a progress bar, a sync counter — and should feel like part of the same mechanism rather than a gesture. Linear gives the stamp a press-platen quality: no acceleration, no settle, it just arrives.

confirmationstate-changesavebadgelinear

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>Patch stamp save — linear</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
     (#d97757 / #b85c3e / #788c5d / #b04a3f). */
  --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));

  /* Clock — every duration in the document runs off --t. */
  --cycle: 3.6s;
  --t: calc(var(--cycle) / var(--speed));

  /* File-specific scratch. */
  --_stitch: 268;
  --_grain: color-mix(in srgb, var(--slate) 12%, transparent);
}

* { box-sizing: border-box; }

html, body { margin: 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);
}

.card {
  width: min(400px, 92vw);
  padding: clamp(16px, 5vw, 26px);
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: center;
  gap: clamp(14px, 4vw, 20px);
  background: var(--paper);
  border: 1px solid var(--oat);
  border-radius: calc(14px * var(--round));
}

.slot {
  position: relative;
  width: clamp(76px, 22vw, 92px);
  height: clamp(76px, 22vw, 92px);
  flex: none;
}

.slot::before {
  content: '';
  position: absolute;
  inset: 6%;
  border-radius: calc(10px * var(--round));
  background:
    repeating-linear-gradient(45deg, var(--_grain) 0 2px, transparent 2px 7px);
  opacity: 0.5;
}

.stamp {
  position: absolute;
  inset: 0;
  transform-origin: 50% 42%;
  animation: drop var(--t) linear infinite;
}

.stamp svg { display: block; width: 100%; height: 100%; overflow: visible; }

.patch-body { fill: var(--clay); }
.patch-edge { fill: var(--clay-d); }

.stitch {
  fill: none;
  stroke: var(--ivory);
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-dasharray: 7 6;
  stroke-dashoffset: var(--_stitch);
  animation: sew var(--t) linear infinite;
  animation-delay: calc(0.16s / var(--speed));
}

.tick {
  fill: none;
  stroke: var(--ivory);
  stroke-width: 7;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 44;
  stroke-dashoffset: 44;
  animation: tick var(--t) linear infinite;
  animation-delay: calc(0.34s / var(--speed));
}

.shock {
  position: absolute;
  inset: 4%;
  border-radius: calc(12px * var(--round));
  border: 2px solid var(--rust);
  opacity: 0;
  animation: shock var(--t) linear infinite;
}

.copy { min-width: 0; }

.label {
  font-size: clamp(14px, 4vw, 16px);
  font-weight: 650;
  letter-spacing: 0.01em;
  position: relative;
  height: 1.25em;
  overflow: hidden;
}

.label span {
  position: absolute;
  inset: 0;
  display: block;
  white-space: nowrap;
}

.label .before {
  color: color-mix(in srgb, var(--slate) 55%, var(--ivory));
  animation: out-line var(--t) linear infinite;
}

.label .after {
  color: var(--olive);
  opacity: 0;
  transform: translateY(60%);
  animation: in-line var(--t) linear infinite;
  animation-delay: calc(0.3s / var(--speed));
}

.sub {
  margin-top: 4px;
  font-size: clamp(11px, 3.2vw, 12.5px);
  line-height: 1.35;
  color: color-mix(in srgb, var(--slate) 48%, var(--ivory));
}

/* Same keyframe percentages as the eased sibling — only the rate between
   them changes, so the two loops stay in lockstep side by side. */
@keyframes drop {
  0%   { transform: translate3d(0, -46%, 0) rotate(-9deg) scale(0.94); opacity: 0; }
  6%   { opacity: 1; }
  13%  { transform: translate3d(0, 0, 0) rotate(-2.5deg) scale(1); opacity: 1; }
  88%  { transform: translate3d(0, 0, 0) rotate(-2.5deg) scale(1); opacity: 1; }
  97%, 100% { transform: translate3d(0, -10%, 0) rotate(-6deg) scale(0.96); opacity: 0; }
}

@keyframes sew {
  0%, 8%  { stroke-dashoffset: var(--_stitch); }
  40%     { stroke-dashoffset: 0; }
  86%     { stroke-dashoffset: 0; }
  100%    { stroke-dashoffset: 0; }
}

@keyframes tick {
  0%, 10% { stroke-dashoffset: 44; }
  34%     { stroke-dashoffset: 0; }
  100%    { stroke-dashoffset: 0; }
}

@keyframes shock {
  0%, 10%  { opacity: 0; transform: scale(1); }
  14%      { opacity: 0.55; transform: scale(1); }
  30%      { opacity: 0; transform: scale(1.16); }
  100%     { opacity: 0; transform: scale(1.16); }
}

@keyframes out-line {
  0%   { opacity: 1; transform: translateY(0); }
  10%  { opacity: 1; transform: translateY(0); }
  22%  { opacity: 0; transform: translateY(-60%); }
  92%  { opacity: 0; transform: translateY(-60%); }
  100% { opacity: 1; transform: translateY(0); }
}

@keyframes in-line {
  0%   { opacity: 0; transform: translateY(60%); }
  14%  { opacity: 1; transform: translateY(0); }
  78%  { opacity: 1; transform: translateY(0); }
  86%, 100% { opacity: 0; transform: translateY(60%); }
}

@media (max-width: 380px) {
  .card { padding: 14px; gap: 12px; }
}

@media (prefers-reduced-motion: reduce) {
  .stamp {
    animation: hold-fade calc(var(--cycle) * 2 / var(--speed)) linear infinite;
    transform: none;
  }
  .shock { animation: none; opacity: 0; }
  .stitch { animation: none; stroke-dashoffset: 0; }
  .tick { animation: none; stroke-dashoffset: 0; }
  .label .before {
    animation: soft-out calc(var(--cycle) * 2 / var(--speed)) linear infinite;
    transform: none;
  }
  .label .after {
    animation: soft-in calc(var(--cycle) * 2 / var(--speed)) linear infinite;
    animation-delay: calc(0.3s / var(--speed));
    transform: none;
  }
  @keyframes hold-fade {
    0%, 100% { opacity: 0.55; }
    45%, 80% { opacity: 1; }
  }
  @keyframes soft-out {
    0%, 100% { opacity: 1; }
    20%, 88% { opacity: 0; }
  }
  @keyframes soft-in {
    0%, 100% { opacity: 0; }
    16%, 78% { opacity: 1; }
  }
}
</style>
</head>
<body>
  <div class="card">
    <div class="slot">
      <div class="stamp">
        <svg viewBox="0 0 100 100" role="img" aria-label="Saved">
          <path class="patch-edge" d="M31 5 L70 8 L95 32 L92 71 L68 95 L29 92 L6 67 L9 29 Z" />
          <path class="patch-body" d="M32 9 L68 12 L91 34 L88 69 L66 91 L31 88 L10 65 L13 31 Z" />
          <path class="stitch" d="M33 17 L67 20 L83 36 L80 67 L64 83 L32 80 L18 63 L21 33 Z" />
          <path class="tick" d="M32 52 L45 65 L69 36" />
        </svg>
      </div>
      <div class="shock"></div>
    </div>
    <div class="copy">
      <div class="label">
        <span class="before">Unsaved changes</span>
        <span class="after">Patched &amp; saved</span>
      </div>
      <div class="sub">Draft copy written to local store</div>
    </div>
  </div>
</body>
</html>