Back to the library

Vault unlock confirm, cascade

Variant: Cascade

The same padlock unlock split into three short steps, each starting 64ms after the last: the shackle lifts, then the check draws, then the label changes. Use it when the order of events matters, when you want the cause (the lock opening) to land before the confirmation text. Each step lasts only 224ms, so the whole thing still finishes by 352ms. The relock plays all at once in 160ms, because an exit shouldn't make you wait through the steps again.

unlockconfirmationstaggersecuritypadlock

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>Vault unlock confirm — cascade</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; /* include ONLY if at least one border-radius is wrapped below */
  --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.2s; /* the authored master loop */
  --t: calc(var(--cycle) / var(--speed));

  --ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* include only if used */
  --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* include only if used */
}

* { box-sizing: border-box; margin: 0; }

body {
  min-height: 100vh;
  display: grid;
  place-items: center;
  background: var(--ivory);
  color: var(--slate);
  font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}

.card {
  width: min(300px, 86vw);
  display: flex;
  align-items: center;
  gap: clamp(12px, 4vw, 16px);
  padding: clamp(14px, 4.5vw, 20px);
  background: var(--paper);
  border: 1px solid var(--oat);
  border-radius: calc(14px * var(--round));
}

.lock {
  width: clamp(40px, 12vw, 52px);
  height: auto;
  flex: none;
  overflow: visible;
}

.shackle {
  fill: none;
  stroke: var(--slate);
  stroke-width: 4;
  stroke-linecap: round;
  animation: shackle var(--t) infinite;
}
.body { fill: var(--slate); }
.tint { fill: var(--olive); opacity: 0; animation: tint var(--t) infinite; }
.keyhole { fill: var(--paper); animation: keyhole var(--t) infinite; }
.check {
  fill: none;
  stroke: var(--paper);
  stroke-width: 3.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  opacity: 0;
  animation: check var(--t) infinite;
}

.text { display: grid; gap: 3px; min-width: 0; }
.label {
  display: grid;
  font-size: clamp(15px, 4.4vw, 17px);
  font-weight: 600;
  letter-spacing: -0.01em;
}
.label span { grid-area: 1 / 1; }
.label .off { animation: label-out var(--t) infinite; }
.label .on { opacity: 0; animation: label-in var(--t) infinite; }
.caption { font-size: clamp(12px, 3.4vw, 13px); opacity: 0.6; }

/* Steps start 2% (64ms at 1x) apart: shackle and tint at 20%, check at 22%, label at 24%. Each step lasts 7% (224ms). The relock is one shared 82%-87% beat. */
@keyframes shackle {
  0%, 20% { transform: translateY(0); animation-timing-function: var(--ease-in-out); }
  27%, 82% { transform: translateY(-7px); animation-timing-function: var(--ease-in-out); }
  87%, 100% { transform: translateY(0); }
}
@keyframes tint {
  0%, 20% { opacity: 0; animation-timing-function: var(--ease-out); }
  27%, 82% { opacity: 1; animation-timing-function: var(--ease-out); }
  87%, 100% { opacity: 0; }
}
@keyframes keyhole {
  0%, 20% { opacity: 1; animation-timing-function: var(--ease-out); }
  25%, 82% { opacity: 0; animation-timing-function: var(--ease-out); }
  87%, 100% { opacity: 1; }
}
@keyframes check {
  0%, 22% { stroke-dashoffset: 1; opacity: 0; animation-timing-function: var(--ease-out); }
  29%, 82% { stroke-dashoffset: 0; opacity: 1; animation-timing-function: var(--ease-out); }
  87% { stroke-dashoffset: 0; opacity: 0; }
  100% { stroke-dashoffset: 1; opacity: 0; }
}
@keyframes label-out {
  0%, 24% { opacity: 1; transform: translateY(0); animation-timing-function: var(--ease-out); }
  29%, 82% { opacity: 0; transform: translateY(-4px); animation-timing-function: var(--ease-out); }
  87%, 100% { opacity: 1; transform: translateY(0); }
}
@keyframes label-in {
  0%, 24% { opacity: 0; transform: translateY(4px); animation-timing-function: var(--ease-out); }
  31%, 82% { opacity: 1; transform: translateY(0); animation-timing-function: var(--ease-out); }
  87% { opacity: 0; transform: translateY(0); }
  100% { opacity: 0; transform: translateY(4px); }
}

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

@media (prefers-reduced-motion: reduce) {
  .shackle { animation: none; }
  .tint, .keyhole, .check, .label .off, .label .on {
    animation-duration: calc(var(--cycle) * 2 / var(--speed));
  }
  /* !important beats the animation layer: the colour and opacity crossfades still loop, but nothing draws or slides. */
  .check { stroke-dashoffset: 0 !important; }
  .label .off, .label .on { transform: none !important; }
}
</style>
</head>
<body>
  <div class="card">
    <svg class="lock" viewBox="0 0 48 48" aria-hidden="true">
      <path class="shackle" d="M17 23V16a7 7 0 0 1 14 0v12" />
      <rect class="body" x="9" y="22" width="30" height="21" rx="5" />
      <rect class="tint" x="9" y="22" width="30" height="21" rx="5" />
      <path class="keyhole" d="M24 28.5a2.75 2.75 0 0 1 1.4 5.1V37h-2.8v-3.4A2.75 2.75 0 0 1 24 28.5z" />
      <path class="check" pathLength="1" d="M17.5 32.5l4.5 4.5 8.5-8.5" />
    </svg>
    <div class="text">
      <div class="label"><span class="off">Locked</span><span class="on">Unlocked</span></div>
      <div class="caption">Release signing key</div>
    </div>
  </div>
</body>
</html>

The variant family

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