Back to the library
Empty state lantern
Variant: Spring overshoot
The springy sibling: the lamp's travel runs on a curve that races past its resting point and eases back, so the light lands at the far edge with a small settle. Use it where the empty state belongs to a playful product and the surface can carry a little personality; skip it in dense or serious tooling. The overshoot is contained by giving the rail visible overflow and parking the lamp's rest position short of the frame edge, so nothing clips at phone widths.
empty-stateambientloaderplaceholderspring
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" />
<meta name="animation-knobs" content="overshoot: ok; speed: 0.7-1.3" />
<title>Empty state lantern — Spring overshoot</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: 5.6s;
--t: calc(var(--cycle) / var(--speed));
--ease-accent: cubic-bezier(0.34, 1.56, 0.64, 1);
/* File-specific scratch. */
--_slot-stagger: calc(0.09s / var(--speed));
--_glow: color-mix(in oklab, var(--clay) 70%, var(--paper));
}
* { 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);
}
.stage {
width: min(420px, 92vw);
padding: clamp(16px, 5vw, 26px);
background: var(--paper);
border: 1px solid var(--oat);
border-radius: calc(14px * var(--round));
box-shadow: 0 1px 0 color-mix(in oklab, var(--oat) 55%, transparent);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: clamp(14px, 4vw, 20px);
}
.title {
font-size: clamp(12px, 3.4vw, 13.5px);
letter-spacing: 0.04em;
font-weight: 600;
color: color-mix(in oklab, var(--slate) 74%, var(--paper));
}
.pip {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--clay);
animation: breathe var(--t) var(--ease-accent) infinite;
}
.rail {
position: relative;
height: 2px;
border-radius: 999px;
background: color-mix(in oklab, var(--oat) 75%, var(--paper));
overflow: visible;
}
.lamp {
position: absolute;
top: 50%;
left: 0;
width: 34%;
height: 2px;
margin-top: -1px;
border-radius: 999px;
background: linear-gradient(90deg,
transparent,
var(--clay) 45%,
var(--rust) 55%,
transparent);
filter: drop-shadow(0 0 6px var(--_glow));
animation: travel var(--t) var(--ease-accent) infinite;
}
.slots {
margin-top: clamp(16px, 4.5vw, 22px);
display: grid;
gap: clamp(9px, 2.6vw, 12px);
}
.slot {
display: flex;
align-items: center;
gap: 10px;
opacity: 0.42;
animation: lift var(--t) var(--ease-accent) infinite;
animation-delay: calc(var(--_i) * var(--_slot-stagger));
}
.slot:nth-child(1) { --_i: 0; }
.slot:nth-child(2) { --_i: 1; }
.slot:nth-child(3) { --_i: 2; }
.dot {
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
border: 1.5px solid color-mix(in oklab, var(--oat) 90%, var(--slate));
}
.bar {
height: 8px;
border-radius: 999px;
background: color-mix(in oklab, var(--oat) 70%, var(--paper));
}
.slot:nth-child(1) .bar { width: 62%; }
.slot:nth-child(2) .bar { width: 44%; }
.slot:nth-child(3) .bar { width: 53%; }
.foot {
margin-top: clamp(16px, 4.5vw, 22px);
font-size: clamp(11px, 3vw, 12px);
color: color-mix(in oklab, var(--slate) 44%, var(--paper));
letter-spacing: 0.02em;
}
/* Same endpoints as the siblings; the curve alone supplies the overshoot,
and the rest point sits inside the card so the spring never clips. */
@keyframes travel {
0% { transform: translateX(-40%); }
62%, 100% { transform: translateX(310%); }
}
@keyframes lift {
0%, 14% { opacity: 0.42; }
34% { opacity: 1; }
62%, 100% { opacity: 0.42; }
}
@keyframes breathe {
0%, 100% { opacity: 0.35; }
34% { opacity: 1; }
70% { opacity: 0.35; }
}
@media (max-width: 380px) {
.stage { padding: 15px; }
.slots { gap: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.lamp {
animation: glow calc(var(--t) * 2) ease-in-out infinite;
left: 33%;
}
.slot { animation-duration: calc(var(--t) * 2); animation-timing-function: ease-in-out; }
.pip { animation-duration: calc(var(--t) * 2); animation-timing-function: ease-in-out; }
@keyframes glow {
0%, 100% { opacity: 0.35; transform: none; }
50% { opacity: 1; transform: none; }
}
@keyframes lift {
0%, 100% { opacity: 0.45; }
50% { opacity: 0.9; }
}
@keyframes breathe {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.9; }
}
}
</style>
</head>
<body>
<div class="stage" role="img" aria-label="Empty list placeholder with a light springing across it">
<div class="head">
<div class="title">Nothing here yet</div>
<div class="pip"></div>
</div>
<div class="rail"><div class="lamp"></div></div>
<div class="slots">
<div class="slot"><span class="dot"></span><span class="bar"></span></div>
<div class="slot"><span class="dot"></span><span class="bar"></span></div>
<div class="slot"><span class="dot"></span><span class="bar"></span></div>
</div>
<div class="foot">The first item will land on this line.</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.