Back to the library

Hinge reveal save

Variant: Standard

A save confirmation where two paper doors swing open on their outer hinges to show a solid colour field with a check drawn on it. Then the status words rise in one after the other. Use it when a save or submit needs a clear moment of confirmation without a toast. The doors open over 256ms on a drawer-style curve and close in 192ms, so leaving the confirmed state is quicker than entering it, and the check starts drawing while the doors are still moving so the reveal reads as one gesture.

confirmationsavehinge3dstate-change

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>Hinge reveal save — Standard</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 */
  --ease-accent: cubic-bezier(0.32, 0.72, 0, 1); /* the snippet's ONE characterful curve, if it has one */

  /* Weight — the only values that differ between siblings. */
  --_tile: 52px;
  --_open: 105deg;
  --_reach: 14px;
  --_stroke: 1.5;
  --_seam: 1px;
  --_field-o: 1;
  --_ink: var(--paper);
  --_shade: 0.45;
  --_rise: 0.45em;
  --_weight: 600;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  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(340px, 92vw);
  display: flex;
  align-items: center;
  gap: clamp(6px, 2vw, 12px);
  padding: clamp(12px, 3.5vw, 18px);
  background: var(--paper);
  border: 1px solid var(--oat);
  border-radius: calc(14px * var(--round));
}

.stage {
  flex: none;
  display: grid;
  place-items: center;
  padding: 4px var(--_reach);
}

.tile {
  position: relative;
  width: var(--_tile);
  height: var(--_tile);
  perspective: calc(var(--_tile) * 4);
}

.field {
  position: absolute;
  inset: 0;
  border-radius: calc(7px * var(--round));
  background: var(--clay);
  opacity: var(--_field-o);
}

.check {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  fill: none;
  stroke: var(--_ink);
  stroke-width: var(--_stroke);
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  opacity: 0;
  animation: draw var(--t) linear infinite;
}

.door {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 50%;
  background: var(--paper);
  border: var(--_seam) solid var(--oat);
  animation: hinge var(--t) linear infinite;
}

/* The inner face darkens as it turns away from the light, so the fold reads as a physical plane. */
.door::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--oat);
  opacity: 0;
  animation: shade var(--t) linear infinite;
}

.door-l {
  --_dir: -1;
  left: 0;
  transform-origin: left center;
  border-radius: calc(7px * var(--round)) 0 0 calc(7px * var(--round));
}

.door-r {
  --_dir: 1;
  right: 0;
  transform-origin: right center;
  border-radius: 0 calc(7px * var(--round)) calc(7px * var(--round)) 0;
}

.copy { min-width: 0; }

.title {
  margin: 0 0 3px;
  font-size: clamp(14px, 3.9vw, 16px);
  font-weight: 600;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.status {
  margin: 0;
  font-size: clamp(12px, 3.4vw, 13px);
  white-space: nowrap;
}

.word {
  display: inline-block;
  animation: rise var(--t) linear infinite both;
}

.word:first-child {
  color: var(--clay-d);
  font-weight: var(--_weight);
  margin-right: 0.3em;
}

.word + .word {
  color: color-mix(in oklch, var(--slate) 55%, var(--paper));
  animation-delay: calc(60ms / var(--speed));
}

/* 8→16% of 3.2s = 256ms open; 76→82% = 192ms close. Exit beats enter. */
@keyframes hinge {
  0%, 8% { transform: rotateY(0deg); animation-timing-function: var(--ease-accent); }
  16%, 76% { transform: rotateY(calc(var(--_open) * var(--_dir))); animation-timing-function: var(--ease-in-out); }
  82%, 100% { transform: rotateY(0deg); }
}

@keyframes shade {
  0%, 8% { opacity: 0; animation-timing-function: var(--ease-accent); }
  16%, 76% { opacity: var(--_shade); animation-timing-function: var(--ease-in-out); }
  82%, 100% { opacity: 0; }
}

/* The check starts drawing while the doors are half open, so it reads as one reveal rather than two steps. */
@keyframes draw {
  0%, 12% { opacity: 0; stroke-dashoffset: 1; }
  12.5% { opacity: 1; stroke-dashoffset: 1; animation-timing-function: var(--ease-out); }
  21%, 73% { opacity: 1; stroke-dashoffset: 0; animation-timing-function: var(--ease-out); }
  77%, 100% { opacity: 0; stroke-dashoffset: 0; }
}

@keyframes rise {
  0%, 14% { opacity: 0; transform: translateY(var(--_rise)); animation-timing-function: var(--ease-out); }
  22%, 72% { opacity: 1; transform: translateY(0); animation-timing-function: var(--ease-out); }
  76%, 100% { opacity: 0; transform: translateY(0); }
}

@keyframes door-fade {
  0%, 8% { opacity: 1; animation-timing-function: var(--ease-out); }
  16%, 76% { opacity: 0; animation-timing-function: var(--ease-out); }
  82%, 100% { opacity: 1; }
}

@keyframes fade {
  0%, 14% { opacity: 0; animation-timing-function: var(--ease-out); }
  22%, 72% { opacity: 1; animation-timing-function: var(--ease-out); }
  76%, 100% { opacity: 0; }
}

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

@media (prefers-reduced-motion: reduce) {
  .door { transform: none; animation: door-fade calc(var(--t) * 2) linear infinite; }
  .door::after { animation: none; opacity: 0; }
  .check { stroke-dashoffset: 0; animation: fade calc(var(--t) * 2) linear infinite; }
  .word { transform: none; animation: fade calc(var(--t) * 2) linear infinite both; }
  .word + .word { animation-delay: calc(60ms / var(--speed)); }
}
</style>
</head>
<body>
  <div class="card">
    <div class="stage" aria-hidden="true">
      <div class="tile">
        <div class="field"></div>
        <svg class="check" viewBox="0 0 24 24"><path pathLength="1" d="M6.5 12.5 L10.5 16.5 L17.5 8.5" /></svg>
        <span class="door door-l"></span>
        <span class="door door-r"></span>
      </div>
    </div>
    <div class="copy">
      <p class="title">Quarterly notes</p>
      <p class="status"><span class="word">Saved</span><span class="word">just now</span></p>
    </div>
  </div>
</body>
</html>

The variant family

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