Edit lease — lapsed
Variant: Lapsed
The same lease when the holder goes quiet. One renewal lands; the next never comes. The dot becomes an empty socket — the failure is an absence, so the shape says it before the rust does — the label reads "renewal missed", and the bar keeps draining at exactly the old slope. Nothing accelerates, because expiry is not an event anyone causes; it is the same clock arriving at zero. The lock opens only on the frame the bar empties, and that gap is the lesson: between the missed beat and the zero the holder still owns the file, a late heartbeat could yet arrive, and a UI that unlocks early is how two people end up editing the same document. The shackle swaps on a hard cut rather than sliding or crossfading, because a lock is open or shut and a half-raised or doubled one claims a state the server never had. After the zero the expired state holds for two full seconds, long enough to read, before the card re-arms in the dark. Use it wherever stale locks are broken by timeout rather than by hand.
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>Edit lease — lapsed</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) 50%, transparent);
--_hair: color-mix(in srgb, var(--slate) 9%, transparent);
--_ink: color-mix(in srgb, var(--slate) 72%, transparent);
/* Clock — 8.4s: two renewal periods of the heartbeat variant (2.4s
each), the last third of the lease running out at the same rate it
always did, then ~2s holding the expired state so it can be read. */
--cycle: 8.4s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 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);
}
.card {
width: min(300px, calc(100vw - 32px));
padding: clamp(14px, 4.5vw, 18px);
border-radius: calc(14px * var(--round));
background: var(--paper);
box-shadow:
0 0 0 1px var(--_hair),
0 1px 2px var(--_hair);
}
.doc {
display: flex;
align-items: center;
gap: 7px;
padding-bottom: 12px;
margin-bottom: 12px;
border-bottom: 1px solid var(--_hair);
font-size: 12.5px;
font-weight: 500;
letter-spacing: -0.005em;
}
.doc svg {
flex: none;
width: 14px;
height: 14px;
fill: none;
stroke: var(--_muted);
stroke-width: 1.4;
stroke-linejoin: round;
}
.hold {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
}
.hold,
.lease {
animation: rearm var(--t) var(--ease-out) infinite;
}
.lock {
flex: none;
width: 16px;
height: 16px;
fill: none;
stroke: var(--_ink);
stroke-width: 1.6;
stroke-linecap: round;
}
.lock rect {
fill: var(--_ink);
stroke: none;
}
/* Two shackles, swapped rather than moved: a lock is open or it is shut,
and a shackle seen halfway up would claim a state the server never had. */
.shut {
animation: shut var(--t) infinite;
}
.open {
opacity: 0;
animation: open var(--t) infinite;
}
.avatar {
flex: none;
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: color-mix(in srgb, var(--olive) 18%, var(--paper));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--olive) 34%, transparent);
font-size: 11px;
font-weight: 600;
color: color-mix(in srgb, var(--olive) 70%, var(--slate));
animation: release var(--t) var(--ease-out) infinite;
}
/* Three labels in one grid cell, so the row never reflows as they trade. */
.who {
flex: 1;
min-width: 0;
display: grid;
font-size: 13px;
font-weight: 500;
letter-spacing: -0.01em;
}
.who span {
grid-area: 1 / 1;
white-space: nowrap;
opacity: 0;
}
.held {
animation: l-held var(--t) var(--ease-out) infinite;
}
.late {
color: color-mix(in srgb, var(--rust) 88%, var(--slate));
animation: l-late var(--t) var(--ease-out) infinite;
}
.free {
color: var(--_muted);
animation: l-free var(--t) var(--ease-out) infinite;
}
.beat {
position: relative;
flex: none;
width: 8px;
height: 8px;
margin-right: 2px;
}
.beat i,
.beat b,
.beat::after {
position: absolute;
inset: 0;
border-radius: 50%;
}
.beat i {
background: var(--olive);
animation: core var(--t) var(--ease-out) infinite;
}
/* The missed beat is drawn as an empty socket, not a red dot: what went
wrong is an absence, and the shape says so before the colour does. */
.beat b {
border: 1.5px solid var(--rust);
opacity: 0;
animation: socket var(--t) var(--ease-out) infinite;
}
.beat::after {
content: '';
border: 1.5px solid var(--olive);
opacity: 0;
animation: ping var(--t) var(--ease-out) infinite;
}
.lease {
position: relative;
height: 4px;
margin-top: 8px;
border-radius: 999px;
background: var(--oat);
}
.fill {
position: absolute;
inset: 0;
border-radius: 999px;
background: var(--olive);
transform-origin: left center;
animation: drain var(--t) infinite;
}
/* Same transform clock as the olive fill, so the two can never part;
only its opacity is its own. */
.fill.overdue {
background: var(--rust);
opacity: 0;
animation:
drain var(--t) infinite,
overdue var(--t) var(--ease-out) infinite;
}
.mark {
position: absolute;
left: 33.333%;
top: -4px;
width: 1.5px;
height: 12px;
margin-left: -0.75px;
border-radius: 1px;
background: color-mix(in srgb, var(--slate) 38%, transparent);
}
.foot {
display: flex;
justify-content: space-between;
margin-top: 10px;
font-size: 11.5px;
color: var(--_muted);
font-variant-numeric: tabular-nums;
}
/* 0–25% drain to the mark, renew in 190ms. 28.6–53.6% drain to the mark
again — and keep going, at exactly the old slope, to empty at 66.07%.
Nothing speeds up when the beat is missed: expiry is not an event the
client causes, it is the same clock arriving at zero. */
@keyframes drain {
0% {
transform: scaleX(1);
animation-timing-function: linear;
}
25% {
transform: scaleX(0.3333);
animation-timing-function: var(--ease-out);
}
27.26%,
28.57% {
transform: scaleX(1);
animation-timing-function: linear;
}
53.57% {
transform: scaleX(0.3333);
animation-timing-function: linear;
}
66.07%,
95.71% {
transform: scaleX(0);
}
96.14%,
100% {
transform: scaleX(1);
}
}
@keyframes overdue {
0%,
53.57% {
opacity: 0;
}
55.71%,
95.71% {
opacity: 1;
}
96.14%,
100% {
opacity: 0;
}
}
@keyframes ping {
0%,
24.94% {
opacity: 0;
transform: scale(1);
}
25% {
opacity: 0.6;
transform: scale(1);
}
28.57%,
100% {
opacity: 0;
transform: scale(2.6);
}
}
@keyframes core {
0%,
53.57% {
opacity: 1;
}
55.29%,
95.71% {
opacity: 0;
}
96.14%,
100% {
opacity: 1;
}
}
@keyframes socket {
0%,
53.57% {
opacity: 0;
}
55.29%,
66.07% {
opacity: 1;
}
68.57%,
95.71% {
opacity: 0.35;
}
96.14%,
100% {
opacity: 0;
}
}
/* The lock opens on the frame the bar empties — a cut, not a fade — and
not when the beat was missed. Between 53.57% and 66.07% Mara still holds it: a late
heartbeat could yet arrive, and taking the lock early is how two people
end up editing the same file. */
@keyframes shut {
0%,
66.07% {
opacity: 1;
}
66.26%,
95.71% {
opacity: 0;
}
96.14%,
100% {
opacity: 1;
}
}
@keyframes open {
0%,
66.07% {
opacity: 0;
}
66.26%,
95.71% {
opacity: 1;
}
96.14%,
100% {
opacity: 0;
}
}
@keyframes release {
0%,
66.07% {
opacity: 1;
}
68.57%,
95.71% {
opacity: 0.32;
}
96.14%,
100% {
opacity: 1;
}
}
/* Labels hand over in sequence — out in 100ms, in over 140ms after it —
so two of them never sit at half opacity on the same frame. */
@keyframes l-held {
0%,
53.57% {
opacity: 1;
}
54.77%,
95.71% {
opacity: 0;
}
96.14%,
100% {
opacity: 1;
}
}
@keyframes l-late {
0%,
54.77% {
opacity: 0;
}
56.4%,
66.07% {
opacity: 1;
}
67.29%,
100% {
opacity: 0;
}
}
@keyframes l-free {
0%,
67.29% {
opacity: 0;
}
68.91%,
95.71% {
opacity: 1;
}
96.14%,
100% {
opacity: 0;
}
}
/* The re-arm happens in the dark: hold row and bar dim out, every other
clock snaps back to its first frame at 95.71–96.14%, and they return. */
@keyframes rearm {
0%,
92.29% {
opacity: 1;
}
94.86%,
96.57% {
opacity: 0;
}
99.57%,
100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
/* Nothing here travels except the bar, and the bar is the datum. Every
clock slows by the same 1.6 so the lock still opens on the frame the
bar empties; the renewal ring becomes a glow in place. */
.hold,
.lease,
.shut,
.open,
.avatar,
.who span,
.beat i,
.beat b,
.fill {
animation-duration: calc(var(--t) * 1.6);
}
.fill.overdue {
animation-duration: calc(var(--t) * 1.6), calc(var(--t) * 1.6);
}
.beat::after {
animation-name: glow;
animation-duration: calc(var(--t) * 1.6);
}
@keyframes glow {
0%,
24.94% {
opacity: 0;
transform: scale(1.8);
}
25% {
opacity: 0.45;
transform: scale(1.8);
}
28.57%,
100% {
opacity: 0;
transform: scale(1.8);
}
}
}
</style>
</head>
<body>
<div
class="card"
role="img"
aria-label="A document locked by Mara. Her lease renews once, then the next heartbeat never arrives: the dot empties, the bar turns rust and keeps draining at the same rate, and only when it reaches zero does the lock open and the row read Unlocked, lease expired"
>
<div class="doc">
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3 1.5h5.2L11 4.3v8.2H3z M8 1.5v3h3" />
</svg>
<span>pricing-q4.md</span>
</div>
<div class="hold">
<svg class="lock" viewBox="0 0 16 16" aria-hidden="true">
<path class="shut" d="M5 7.5V5.2a3 3 0 0 1 6 0v2.3" />
<path class="open" d="M11 7.5V4a3 3 0 0 0-6 0v.4" />
<rect x="3" y="7" width="10" height="7.5" rx="1.8" />
</svg>
<span class="avatar">MO</span>
<span class="who">
<span class="held">Mara is editing</span>
<span class="late">Mara · renewal missed</span>
<span class="free">Unlocked · lease expired</span>
</span>
<span class="beat"><i></i><b></b></span>
</div>
<div class="lease"><i class="fill"></i><i class="fill overdue"></i><b class="mark"></b></div>
<div class="foot"><span>Lease 30s</span><span>renews at 10s</span></div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.