Toast — held by hover
Variant: Held by hover
A pointer reaches a toast and the only thing that changes is that nothing changes: the lifetime rail stops dead at 56% and holds for over two seconds, then resumes at exactly the rate it had. The freeze fires on the frame the pointer crosses the edge, mid-flight — pause-on-hover is an enter event, not a reward for settling — and nothing else about the surface acknowledges the hover, because the freeze is the acknowledgement and any lift or glow beside it would be a second message. The resumed drain does not hurry: a suspended deadline has no lost time to make up, and a rail that drained faster after the pointer left would be punishing the user for having read it. Reach for it to document pause-on-hover, the contract every toast library promises and no interface ever shows.
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>Toast — held by hover</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)
);
/* Clock — 7.2s: 1.4s of draining, 2.2s held under the pointer, 1.8s
draining again at the identical rate. */
--cycle: 7.2s;
--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);
}
.frame {
position: relative;
width: clamp(230px, 56vw, 320px);
height: min(64vh, 72vw, 210px);
border-radius: calc(12px * var(--round));
background: var(--paper);
border: 1px solid color-mix(in srgb, var(--slate) 10%, transparent);
overflow: hidden;
}
.page {
position: absolute;
inset: 0;
padding: 16px;
display: grid;
gap: 10px;
align-content: start;
}
.bar {
height: 10px;
border-radius: 5px;
background: var(--oat);
}
.page .head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.page .dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--oat);
flex: none;
}
.slot {
position: absolute;
left: 0;
right: 0;
bottom: 12px;
display: grid;
justify-items: center;
}
/* The pointer is a sibling of the toast, not a child — the toast clips
its own corners (the rail needs that), and the pointer has to be free
to arrive from outside them. */
.anchor {
position: relative;
}
.toast {
position: relative;
display: flex;
align-items: center;
gap: 8px;
width: 178px;
padding: 9px 12px 11px;
border-radius: calc(9px * var(--round));
background: var(--paper);
border: 1px solid color-mix(in srgb, var(--slate) 12%, transparent);
box-shadow: 0 6px 18px color-mix(in srgb, var(--slate) 12%, transparent);
overflow: hidden;
opacity: 0;
transform: translateY(16px);
animation: toast var(--t) linear infinite;
}
.toast .pip {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--clay);
flex: none;
}
.toast .label {
font-size: 11.5px;
font-weight: 500;
letter-spacing: -0.005em;
white-space: nowrap;
}
/* The freeze is the piece. While the pointer is over the toast the rail
holds at 56% — not slower, stopped — and when the pointer leaves it
resumes at exactly the rate it had, because a deadline that was
suspended has no lost time to make up. */
.rail {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: var(--clay);
transform-origin: left center;
animation: rail var(--t) linear infinite;
}
.hand {
position: absolute;
left: 0;
top: 0;
width: 15px;
height: 15px;
animation: hand var(--t) infinite;
}
.arrow {
position: absolute;
inset: 0;
color: var(--slate);
opacity: 0;
animation: arrow var(--t) infinite;
}
.arrow svg {
width: 100%;
height: 100%;
display: block;
filter: drop-shadow(0 1px 2px color-mix(in srgb, var(--slate) 28%, transparent));
}
/* var() does not resolve inside an SVG presentation attribute, so the
pointer's fill has to be set from CSS to stay inside the token API. */
.arrow path {
fill: var(--paper);
}
@keyframes toast {
0%,
4% {
opacity: 0;
transform: translateY(16px);
animation-timing-function: var(--ease-out);
}
7.6%,
83% {
opacity: 1;
transform: translateY(0);
animation-timing-function: var(--ease-out);
}
85.5%,
100% {
opacity: 0;
transform: translateY(10px);
}
}
/* 56% left when the clock stops: the two draining legs cover 19.4% and
25% of the cycle, so equal rates put the split at 19.4/44.4. The
freeze lands at 27% — the frame the pointer crosses the toast's edge
mid-flight, because pause-on-hover fires on enter, not on the pointer
coming to rest. Linear throughout — a timeout is a clock, held or
not. */
@keyframes rail {
0%,
7.6% {
transform: scaleX(1);
animation-timing-function: linear;
}
27% {
transform: scaleX(0.563);
animation-timing-function: linear;
}
58% {
transform: scaleX(0.563);
animation-timing-function: linear;
}
83%,
100% {
transform: scaleX(0);
}
}
/* Positions are relative to the toast, not the stage, so the approach
reads the same at every card size. */
@keyframes hand {
0%,
24% {
transform: translate(150px, 64px);
animation-timing-function: var(--ease-out);
}
29%,
58% {
transform: translate(130px, 14px);
animation-timing-function: var(--ease-out);
}
63%,
100% {
transform: translate(170px, 90px);
}
}
@keyframes arrow {
0%,
22.5% {
opacity: 0;
animation-timing-function: ease;
}
24%,
61% {
opacity: 1;
animation-timing-function: ease;
}
64%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* The pointer cuts between its three stations instead of travelling,
the toast crossfades in place, and the rail — the subject — keeps
draining and freezing exactly as before. */
.toast {
transform: translateY(0);
animation-name: toast-rm;
}
.hand {
animation-name: hand-step;
}
.toast,
.rail,
.hand,
.arrow {
animation-duration: calc(var(--t) * 1.6);
}
@keyframes toast-rm {
0%,
4% {
opacity: 0;
animation-timing-function: ease;
}
7.6%,
83% {
opacity: 1;
animation-timing-function: ease;
}
85.5%,
100% {
opacity: 0;
}
}
@keyframes hand-step {
0%,
24% {
transform: translate(150px, 64px);
animation-timing-function: steps(1, end);
}
29%,
58% {
transform: translate(130px, 14px);
animation-timing-function: steps(1, end);
}
63%,
100% {
transform: translate(170px, 90px);
}
}
}
</style>
</head>
<body>
<div
class="frame"
role="img"
aria-label="A toast whose lifetime bar drains, freezes while a pointer rests on it, then resumes and expires after the pointer leaves"
>
<div class="page">
<div class="head">
<span class="dot"></span>
<span class="bar" style="width: 42%"></span>
</div>
<span class="bar" style="width: 88%"></span>
<span class="bar" style="width: 74%"></span>
<span class="bar" style="width: 81%"></span>
</div>
<div class="slot">
<div class="anchor">
<div class="toast">
<span class="pip"></span>
<span class="label">Export finished</span>
<span class="rail"></span>
</div>
<span class="hand">
<span class="arrow">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M5 2.5 19.5 12 13 13.2l3 6.4-2.7 1.3-3-6.4L5 19z"
stroke="currentColor"
stroke-width="1.7"
stroke-linejoin="round"
/>
</svg>
</span>
</span>
</div>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.