Anchored — tooltip on intent
Variant: Tooltip on intent
The member of the family whose animation is almost entirely a wait. A tooltip that opens the instant a pointer touches it fires on every accidental crossing, so the real control is a 400ms dwell before anything is allowed to appear — and because that delay is invisible in every shipped implementation, this one draws it as a ring the pointer has to close. The first target is crossed in 267ms and the ring is abandoned two thirds of the way round; it resets to empty rather than draining, since intent is not a quantity you keep some of after the hand has left. The second is dwelled on and fires. The third gets no ring and no delay at all, which is the grace window every tooltip group needs: once one is open the next is a move within a component, not a new arrival. The tips themselves travel 3px and never scale — 94ms is not long enough to read a curve, and scaling a 20px chip is only ever visible as the type wobbling.
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>Anchored — tooltip on intent</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, and the numbers that matter in it are the two waits: a
400ms open delay before the first tip and none at all before the
second. The tips themselves cost 94ms in and 72ms out. */
--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);
}
/* Every position in the piece is a pixel inside this rail, so the
choreography is identical at every stage size — only the rail moves.
There is no frame around it on purpose: the tooltips, the pointer and
its timer all live outside the toolbar's own box, and a card drawn
round them would only be a second edge for them to avoid. */
.rail {
position: relative;
width: 126px;
height: 36px;
padding: 5px;
display: flex;
gap: 10px;
border-radius: calc(9px * var(--round));
background: var(--paper);
border: 1px solid color-mix(in srgb, var(--slate) 10%, transparent);
box-shadow: 0 6px 18px color-mix(in srgb, var(--slate) 10%, transparent);
}
.btn {
width: 32px;
height: 26px;
display: grid;
place-items: center;
border-radius: calc(6px * var(--round));
color: color-mix(in srgb, var(--slate) 62%, transparent);
background: transparent;
}
.btn svg {
width: 14px;
height: 14px;
display: block;
}
.b1 {
animation: lit1 var(--t) infinite;
}
.b2 {
animation: lit2 var(--t) infinite;
}
.b3 {
animation: lit3 var(--t) infinite;
}
/* Tips travel 3px and never scale. A surface up for 94ms has no time to
express a scale — the eye reads the arrival, not the curve — and scaling
a 20px chip is only ever visible as the type wobbling. */
.tip {
position: absolute;
bottom: calc(100% + 9px);
padding: 4px 8px;
border-radius: calc(6px * var(--round));
background: var(--slate);
color: var(--ivory);
font-size: 11px;
font-weight: 500;
line-height: 1.2;
letter-spacing: -0.005em;
white-space: nowrap;
opacity: 0;
transform: translate(-50%, 3px);
}
.tip::after {
content: '';
position: absolute;
left: 50%;
top: 100%;
width: 6px;
height: 6px;
margin-left: -3px;
margin-top: -3px;
background: var(--slate);
transform: rotate(45deg);
}
.tip-2 {
left: 63px;
animation: tip2 var(--t) infinite;
}
.tip-3 {
left: 105px;
animation: tip3 var(--t) 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);
}
/* The open delay is invisible in every real tooltip, which is why it is
the thing people get wrong. Here it is drawn: a ring that has to close
before anything is allowed to open. */
.dwell {
position: absolute;
left: 10px;
top: 12px;
width: 15px;
height: 15px;
opacity: 0;
animation: dwell var(--t) infinite;
}
.dwell svg {
width: 100%;
height: 100%;
display: block;
transform: rotate(-90deg);
}
.dwell .track {
stroke: color-mix(in srgb, var(--slate) 16%, transparent);
}
.dwell .arc {
stroke: var(--clay);
stroke-dasharray: 100;
stroke-dashoffset: 100;
animation: fill var(--t) infinite;
}
@keyframes hand {
0%,
4% {
transform: translate(-34px, 30px);
animation-timing-function: var(--ease-out);
}
/* Passing over. 267ms on the target — a real hand crossing a toolbar on
its way somewhere else, and the interval every tooltip has to survive
without firing. */
7.4%,
11.1% {
transform: translate(19px, 16px);
animation-timing-function: var(--ease-out);
}
/* Stopping. */
14.3%,
44% {
transform: translate(61px, 14px);
animation-timing-function: var(--ease-out);
}
/* Moving on inside the grace window. */
47.2%,
72% {
transform: translate(103px, 15px);
animation-timing-function: var(--ease-out);
}
76%,
100% {
transform: translate(158px, 4px);
}
}
@keyframes arrow {
0%,
2.4% {
opacity: 0;
animation-timing-function: ease;
}
4%,
74% {
opacity: 1;
animation-timing-function: ease;
}
77%,
100% {
opacity: 0;
}
}
@keyframes dwell {
0%,
7.4% {
opacity: 0;
animation-timing-function: ease;
}
8.2%,
10.7% {
opacity: 1;
animation-timing-function: ease;
}
11.2%,
14.4% {
opacity: 0;
animation-timing-function: ease;
}
15.1%,
19.9% {
opacity: 1;
animation-timing-function: ease;
}
20.6%,
100% {
opacity: 0;
}
}
/* Linear both times, because a wait is a clock. The first run is abandoned
two thirds of the way round and resets to empty rather than draining —
intent is not a quantity you keep some of after the pointer has left. */
@keyframes fill {
0%,
7.4% {
stroke-dashoffset: 100;
animation-timing-function: linear;
}
11.1% {
stroke-dashoffset: 33;
animation-timing-function: steps(1, end);
}
14.3% {
stroke-dashoffset: 100;
animation-timing-function: linear;
}
19.9%,
100% {
stroke-dashoffset: 0;
}
}
@keyframes tip2 {
0%,
19.9% {
opacity: 0;
transform: translate(-50%, 3px);
animation-timing-function: var(--ease-out);
}
21.2%,
44% {
opacity: 1;
transform: translate(-50%, 0);
animation-timing-function: ease;
}
45%,
100% {
opacity: 0;
transform: translate(-50%, 2px);
}
}
/* No ring precedes this one and no delay either: the group is already
warm, so the second tip is up 94ms after the pointer lands. */
@keyframes tip3 {
0%,
47.2% {
opacity: 0;
transform: translate(-50%, 3px);
animation-timing-function: var(--ease-out);
}
48.5%,
73.8% {
opacity: 1;
transform: translate(-50%, 0);
animation-timing-function: ease;
}
74.8%,
100% {
opacity: 0;
transform: translate(-50%, 2px);
}
}
@keyframes lit1 {
0%,
7.2% {
background: transparent;
animation-timing-function: ease;
}
8.2%,
10.6% {
background: color-mix(in srgb, var(--slate) 7%, transparent);
animation-timing-function: ease;
}
11.8%,
100% {
background: transparent;
}
}
@keyframes lit2 {
0%,
14.1% {
background: transparent;
animation-timing-function: ease;
}
15.1%,
44% {
background: color-mix(in srgb, var(--slate) 7%, transparent);
animation-timing-function: ease;
}
45.2%,
100% {
background: transparent;
}
}
@keyframes lit3 {
0%,
47% {
background: transparent;
animation-timing-function: ease;
}
48%,
72% {
background: color-mix(in srgb, var(--slate) 7%, transparent);
animation-timing-function: ease;
}
73.2%,
100% {
background: transparent;
}
}
@media (prefers-reduced-motion: reduce) {
/* The pointer stops travelling and cuts between targets instead, which
costs nothing here: this piece is about when things appear, not how
they get there. The dwell ring keeps filling — it is the subject, and
it is a clock rather than a flourish — and the tips keep their fade
and lose their 3px. */
.hand {
animation-name: hand-step;
}
.tip-2 {
animation-name: tip2-rm;
}
.tip-3 {
animation-name: tip3-rm;
}
.hand,
.arrow,
.dwell,
.dwell .arc,
.tip-2,
.tip-3,
.b1,
.b2,
.b3 {
animation-duration: calc(var(--t) * 1.6);
}
@keyframes hand-step {
0%,
4% {
transform: translate(-34px, 30px);
animation-timing-function: steps(1, end);
}
7.4%,
11.1% {
transform: translate(19px, 16px);
animation-timing-function: steps(1, end);
}
14.3%,
44% {
transform: translate(61px, 14px);
animation-timing-function: steps(1, end);
}
47.2%,
72% {
transform: translate(103px, 15px);
animation-timing-function: steps(1, end);
}
76%,
100% {
transform: translate(158px, 4px);
}
}
@keyframes tip2-rm {
0%,
19.9% {
opacity: 0;
transform: translate(-50%, 0);
animation-timing-function: ease;
}
21.2%,
44% {
opacity: 1;
transform: translate(-50%, 0);
animation-timing-function: ease;
}
45%,
100% {
opacity: 0;
transform: translate(-50%, 0);
}
}
@keyframes tip3-rm {
0%,
47.2% {
opacity: 0;
transform: translate(-50%, 0);
animation-timing-function: ease;
}
48.5%,
73.8% {
opacity: 1;
transform: translate(-50%, 0);
animation-timing-function: ease;
}
74.8%,
100% {
opacity: 0;
transform: translate(-50%, 0);
}
}
}
</style>
</head>
<body>
<div
class="rail"
role="img"
aria-label="A pointer crossing three toolbar buttons: the first is passed over and shows nothing, the second is dwelled on until a timer ring closes and its tooltip opens, and the third opens its tooltip immediately"
>
<span class="btn b1">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 7h16M4 12h16M4 17h10"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
/>
</svg>
</span>
<span class="btn b2">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect
x="8.5"
y="8.5"
width="11"
height="11"
rx="2.5"
stroke="currentColor"
stroke-width="2.2"
/>
<path
d="M15.5 4.5h-11v11"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<span class="btn b3">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5M4.5 14v4a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2v-4"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<span class="tip tip-2">Duplicate</span>
<span class="tip tip-3">Share</span>
<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 class="dwell">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle class="track" cx="12" cy="12" r="8.5" stroke-width="3" />
<circle
class="arc"
cx="12"
cy="12"
r="8.5"
pathLength="100"
stroke-width="3"
stroke-linecap="round"
/>
</svg>
</span>
</span>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.