The tooltip that argues
Click to cycle the requested side, or drag the button into a corner: data-⚓️-try="flip-all" lets the browser overrule the request, and @container anchored(fallback) queries report asked vs. got.
<header class="intro">
<h1>The tooltip that argues</h1>
<p>
Click the button to ask for a side (<code>data-⚓️-side</code> cycles above → after → below → before).
Drag it toward an edge: <code>data-⚓️-try="flip-all"</code> lets the browser overrule you when
there's no room, and the tooltip reports <em>asked vs got</em> from an
<code>@container anchored(fallback)</code> query. No JS measures the tooltip.
</p>
<div class="controls">
<label class="xray"><input type="checkbox" id="xray"> X-ray</label>
<span class="hint">Try the corners:</span>
<button type="button" data-corner="top-left">↖</button>
<button type="button" data-corner="top-right">↗</button>
<button type="button" data-corner="center">•</button>
<button type="button" data-corner="bottom-left">↙</button>
<button type="button" data-corner="bottom-right">↘</button>
</div>
</header>
<button class="subject" id="subject" type="button" data-corner="center"
data-⚓️-name="--subject" aria-describedby="tip">
<span class="grip" aria-hidden="true">⠿</span> <span>Ask<span class="long"> for a side</span></span>
</button>
<div class="tip" id="tip" popover="manual" role="tooltip"
data-⚓️-to="--subject" data-⚓️-side="above" data-⚓️-align-x="center" data-⚓️-try="flip-all">
<div class="card">
<span class="arrow" aria-hidden="true"></span>
<p class="argue"></p>
<dl class="readout">
<div><dt>asked</dt><dd class="asked"></dd></div>
<div><dt>got</dt><dd class="got"></dd></div>
</dl>
</div>
</div>/* Demo styling only. The tooltip's place is data-⚓️-side (what you asked
for) + data-⚓️-try="flip-all" (what the browser allows). Everything the
readout says comes from CSS: the side attribute for "asked" and
@container anchored(fallback: …) for "got". */
:root {
color-scheme: light;
--bg: oklch(97% 0.008 85);
--ink: oklch(24% 0.02 60);
--muted: oklch(48% 0.02 60);
--card: oklch(99.5% 0.004 85);
--line: oklch(85% 0.02 70);
--ok: oklch(55% 0.13 155);
--warn: oklch(58% 0.19 30);
background:
radial-gradient(circle, oklch(88% 0.015 70) 1px, transparent 1.5px) 0 0 / 22px 22px,
var(--bg);
color: var(--ink);
font: 400 1rem/1.45 system-ui, sans-serif;
}
*, ::before, ::after { box-sizing: border-box; }
/* The flip needs the anchor on a non-scrolling viewport. */
html, body { block-size: 100%; overflow: hidden; }
body { margin: 0; }
.intro {
max-inline-size: 64ch;
padding: clamp(1rem, 4vw, 2rem);
position: relative;
z-index: 1;
pointer-events: none;
}
.intro :is(a, button, label, input) { pointer-events: auto; }
h1 { margin: 0 0 0.3rem; font-size: 1.4rem; letter-spacing: -0.01em; }
.intro p { margin: 0 0 0.75rem; color: var(--muted); font-size: 0.92rem; }
code { font: 0.85em ui-monospace, monospace; color: oklch(45% 0.12 30); }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; }
.hint { color: var(--muted); font-size: 0.85rem; margin-inline-start: 0.75rem; }
.controls button {
font: inherit;
inline-size: 2rem;
block-size: 2rem;
border-radius: 0.5rem;
border: 1px solid var(--line);
background: var(--card);
color: var(--ink);
cursor: pointer;
}
.xray { display: inline-flex; gap: 0.4rem; align-items: center; color: var(--muted); font-size: 0.9rem; cursor: pointer; }
button:focus-visible { outline: 2px solid var(--warn); outline-offset: 2px; }
/* ---------- the anchor: fixed on the viewport, draggable ---------- */
.subject {
position: fixed;
inset: auto;
font: 600 0.95rem/1 system-ui, sans-serif;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.8rem 1.1rem;
border-radius: 999px;
border: 1px solid oklch(35% 0.03 60);
background: var(--ink);
color: var(--bg);
box-shadow: 0 0.5rem 1.5rem oklch(30% 0.03 60 / 0.25);
cursor: grab;
touch-action: none;
user-select: none;
}
.subject:active { cursor: grabbing; }
.grip { opacity: 0.6; font-size: 1.1em; }
.subject[data-corner="center"] { inset: 0; margin: auto; inline-size: fit-content; block-size: fit-content; }
.subject[data-corner="top-left"] { inset-block-start: 0.75rem; inset-inline-start: 0.75rem; }
.subject[data-corner="top-right"] { inset-block-start: 0.75rem; inset-inline-end: 0.75rem; }
.subject[data-corner="bottom-left"] { inset-block-end: 0.75rem; inset-inline-start: 0.75rem; }
.subject[data-corner="bottom-right"] { inset-block-end: 0.75rem; inset-inline-end: 0.75rem; }
/* ---------- the tooltip ---------- */
.tip {
margin: 0.7rem;
padding: 0;
border: 0;
background: none;
overflow: visible;
inline-size: max-content;
max-inline-size: min(17rem, 80vw);
}
.card {
--accent: var(--ok);
--argue: "Fine. " var(--got) " it is.";
position: relative;
padding: 0.75rem 0.9rem 0.65rem;
border-radius: 0.8rem;
background: var(--card);
border: 1.5px solid var(--accent);
box-shadow: 0 0.6rem 1.8rem oklch(30% 0.03 60 / 0.18);
}
.arrow {
position: absolute;
inset-block-start: var(--at);
inset-inline-start: var(--al);
inline-size: 0.75rem;
aspect-ratio: 1;
translate: -50% -50%;
rotate: 45deg;
background: var(--card);
border: 1.5px solid var(--accent);
clip-path: var(--clip);
}
.argue { margin: 0 0 0.45rem; font-weight: 650; }
.argue::before { content: var(--argue); }
.readout { display: flex; gap: 1.25rem; margin: 0; font: 0.8rem/1.3 ui-monospace, monospace; }
.readout div { display: flex; gap: 0.4rem; }
.readout dt { color: var(--muted); }
.readout dd { margin: 0; font-weight: 700; }
.asked::before { content: var(--asked); }
.got::before { content: var(--got); color: var(--accent); }
/* What you asked for: straight from the side attribute. The arrow points
back at the anchor (clip keeps just the half that sticks out). */
.tip[data-⚓️-side="above"] .card { --asked: "above"; --got: "above"; --at: 100%; --al: 50%; --clip: polygon(100% 0, 100% 100%, 0 100%); }
.tip[data-⚓️-side="below"] .card { --asked: "below"; --got: "below"; --at: 0%; --al: 50%; --clip: polygon(0 0, 100% 0, 0 100%); }
.tip[data-⚓️-side="before"] .card { --asked: "before"; --got: "before"; --at: 50%; --al: 100%; --clip: polygon(0 0, 100% 0, 100% 100%); }
.tip[data-⚓️-side="after"] .card { --asked: "after"; --got: "after"; --at: 50%; --al: 0%; --clip: polygon(0 0, 0 100%, 100% 100%); }
/* What the browser gave you: the tooltip is an anchored query container
(the library sets container-type: anchored), so its contents can ask
which fallback won. */
@container anchored(fallback: flip-block) {
.tip[data-⚓️-side="above"] .card { --got: "below"; --at: 0%; --clip: polygon(0 0, 100% 0, 0 100%); --accent: var(--warn); --argue: "No room " var(--asked) ". Going " var(--got) "."; }
.tip[data-⚓️-side="below"] .card { --got: "above"; --at: 100%; --clip: polygon(100% 0, 100% 100%, 0 100%); --accent: var(--warn); --argue: "No room " var(--asked) ". Going " var(--got) "."; }
}
@container anchored(fallback: flip-inline) {
.tip[data-⚓️-side="before"] .card { --got: "after"; --al: 0%; --clip: polygon(0 0, 0 100%, 100% 100%); --accent: var(--warn); --argue: "No room " var(--asked) ". Going " var(--got) "."; }
.tip[data-⚓️-side="after"] .card { --got: "before"; --al: 100%; --clip: polygon(0 0, 100% 0, 100% 100%); --accent: var(--warn); --argue: "No room " var(--asked) ". Going " var(--got) "."; }
}
@container anchored(fallback: flip-block flip-inline) {
.tip[data-⚓️-side="above"] .card { --got: "below"; --at: 0%; --clip: polygon(0 0, 100% 0, 0 100%); --accent: var(--warn); --argue: "No room " var(--asked) ". Going " var(--got) "."; }
.tip[data-⚓️-side="below"] .card { --got: "above"; --at: 100%; --clip: polygon(100% 0, 100% 100%, 0 100%); --accent: var(--warn); --argue: "No room " var(--asked) ". Going " var(--got) "."; }
.tip[data-⚓️-side="before"] .card { --got: "after"; --al: 0%; --clip: polygon(0 0, 0 100%, 100% 100%); --accent: var(--warn); --argue: "No room " var(--asked) ". Going " var(--got) "."; }
.tip[data-⚓️-side="after"] .card { --got: "before"; --al: 100%; --clip: polygon(0 0, 100% 0, 100% 100%); --accent: var(--warn); --argue: "No room " var(--asked) ". Going " var(--got) "."; }
}
/* ---------- X-ray ---------- */
body:has(#xray:checked) [data-⚓️-name] { outline: 2px dashed oklch(60% 0.2 280); outline-offset: 4px; }
body:has(#xray:checked) .tip { outline: 1px dashed oklch(60% 0.2 280 / 0.7); }
body:has(#xray:checked) .card::after {
content: "side=" var(--asked) " try=flip-all";
position: absolute;
inset-block-end: calc(100% + 0.2rem);
inset-inline-start: 0;
font: 700 0.62rem/1 ui-monospace, monospace;
color: oklch(50% 0.2 280);
white-space: pre;
}
/* Small screens: a shorter anchor and a narrower tooltip so a before/after
request has somewhere to go. */
@media (width < 36rem) {
.subject .long { display: none; }
.tip { max-inline-size: 36vw; }
.readout { flex-direction: column; gap: 0.1rem; }
}// State + pointer logic only. The tooltip's position (and whether the
// browser overrules the requested side) is all CSS: data-⚓️-side +
// data-⚓️-try="flip-all", reported via @container anchored(fallback).
const subject = document.getElementById("subject");
let tip = document.getElementById("tip");
const SIDES = ["above", "after", "below", "before"];
const reduce = matchMedia("(prefers-reduced-motion: reduce)");
tip.showPopover();
// Chrome remembers the last fallback that worked and keeps using it, even
// after the side changes, the fallbacks are removed or the popover is
// re-shown. Only a fresh element re-picks from the requested side, so
// hand the browser a clone whenever the request (or the anchor spot) changes.
function reconsider() {
const fresh = tip.cloneNode(true);
tip.replaceWith(fresh);
tip = fresh;
tip.showPopover();
}
function ask(side) {
reconsider();
tip.setAttribute("data-⚓️-side", side);
const vertical = side === "above" || side === "below";
// centre on the cross axis
tip.setAttribute(`data-⚓️-align-${vertical ? "x" : "y"}`, "center");
tip.removeAttribute(`data-⚓️-align-${vertical ? "y" : "x"}`);
if (!reduce.matches) {
tip.querySelector(".card").animate({ scale: [0.94, 1] }, { duration: 220, easing: "cubic-bezier(.25,1,.5,1)" });
}
}
// click = ask for the next side; drag = move the anchor around
let drag = null;
let dragged = false;
subject.addEventListener("pointerdown", (e) => {
const r = subject.getBoundingClientRect(); // where the pointer grabbed the button
drag = { dx: e.clientX - r.left, dy: e.clientY - r.top, x: e.clientX, y: e.clientY };
dragged = false;
subject.setPointerCapture(e.pointerId);
});
subject.addEventListener("pointermove", (e) => {
if (!drag) return;
if (!dragged && Math.hypot(e.clientX - drag.x, e.clientY - drag.y) < 5) return;
dragged = true;
subject.dataset.corner = "free";
subject.style.left = `${e.clientX - drag.dx}px`;
subject.style.top = `${e.clientY - drag.dy}px`;
});
subject.addEventListener("pointerup", () => {
drag = null;
if (dragged) reconsider();
});
subject.addEventListener("click", () => {
if (dragged) return;
const next = SIDES[(SIDES.indexOf(tip.getAttribute("data-⚓️-side")) + 1) % SIDES.length];
ask(next);
});
for (const btn of document.querySelectorAll(".controls [data-corner]")) {
btn.addEventListener("click", () => {
subject.style.left = subject.style.top = "";
subject.dataset.corner = btn.dataset.corner;
reconsider();
});
}data-⚓️-align-xdata-⚓️-namedata-⚓️-sidedata-⚓️-todata-⚓️-try