Drag to dock: chat-head snapping with data-⚓️-side
Drag a picture-in-picture camera bubble and release: it snaps to the nearest side of the call and swings there.
<main class="demo">
<header class="intro">
<h1>Drag to dock</h1>
<p>
Drag your camera bubble anywhere and let go: it snaps to the nearest side of the call.
The script only decides <em>which</em> side and writes it to <code>data-⚓️-side</code>;
the dock position, the lane size (<code>data-⚓️-sync-*</code>) and the swing
(<code>data-⚓️-tween</code>) are all the library. Keyboard: focus the bubble, use the arrow keys.
</p>
</header>
<div class="bar">
<output class="status" id="status" aria-live="polite">Docked <b>after</b></output>
<label class="xray-toggle"><input type="checkbox" id="xray"> X-ray</label>
</div>
<div class="stage" data-⚓️-root>
<figure class="feed" data-⚓️-name="--feed">
<div class="screen" aria-hidden="true">
<span class="slide-title">Q4 layout review</span>
<span class="bars"><i></i><i></i><i></i><i></i><i></i></span>
</div>
<figcaption><span class="rec"></span> Priya is presenting</figcaption>
</figure>
<!-- Drop hints: the same four lanes, one per side, anchored to the feed. -->
<div class="zone" data-zone="above" data-⚓️-to="--feed" data-⚓️-side="above" data-⚓️-align-leading data-⚓️-sync-width data-⚓️-sync-height></div>
<div class="zone" data-zone="below" data-⚓️-to="--feed" data-⚓️-side="below" data-⚓️-align-leading data-⚓️-sync-width data-⚓️-sync-height></div>
<div class="zone" data-zone="before" data-⚓️-to="--feed" data-⚓️-side="before" data-⚓️-align-top data-⚓️-sync-width data-⚓️-sync-height></div>
<div class="zone" data-zone="after" data-⚓️-to="--feed" data-⚓️-side="after" data-⚓️-align-top data-⚓️-sync-width data-⚓️-sync-height></div>
<!-- The dock lane. The bubble centres itself inside it. -->
<div
class="lane"
data-⚓️-to="--feed"
data-⚓️-side="after"
data-⚓️-align-top
data-⚓️-sync-width
data-⚓️-sync-height
data-⚓️-tween
data-⚓️-transition="morph"
>
<button class="pip" type="button" aria-describedby="status" aria-label="Your camera. Drag it, or press an arrow key to dock it to that side of the call.">
<span class="face" aria-hidden="true">You</span>
<span class="mic" aria-hidden="true"></span>
</button>
</div>
</div>
</main>/* Demo styling only. The lane and drop hints are positioned and sized by
data-⚓️-* attributes; this file sets looks and the lane thickness. */
:root {
color-scheme: dark;
--bg: oklch(15% 0.015 280);
--ink: oklch(92% 0.01 280);
--muted: oklch(68% 0.02 280);
--line: oklch(32% 0.03 280);
--accent: oklch(80% 0.15 160);
--ease: cubic-bezier(0.25, 1, 0.5, 1);
background: var(--bg);
color: var(--ink);
font: 400 1rem/1.5 system-ui, sans-serif;
}
body { margin: 0; }
.demo {
max-width: 60rem;
margin-inline: auto;
padding: clamp(1rem, 4vw, 2.5rem);
display: grid;
gap: 1.25rem;
}
.intro h1 { margin: 0 0 0.35rem; font-size: 1.5rem; font-weight: 650; letter-spacing: -0.01em; }
.intro p { margin: 0; color: var(--muted); max-width: 64ch; }
code { font: 0.88em ui-monospace, monospace; color: var(--accent); }
.bar { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
.status { font: 0.9rem ui-monospace, monospace; color: var(--muted); }
.status b { color: var(--accent); font-weight: 600; }
.xray-toggle { display: flex; gap: 0.4rem; align-items: center; color: var(--muted); font-size: 0.9rem; cursor: pointer; }
.xray-toggle input { accent-color: var(--accent); }
/* ---------- stage ---------- */
.stage {
--lane: clamp(4.75rem, 15vw, 7.5rem);
display: grid;
place-items: center;
/* room for a lane on every side of the feed, plus breathing space */
padding: calc(var(--lane) + 1.25rem) calc(var(--lane) + 0.5rem);
border: 1px solid var(--line);
border-radius: 1rem;
background:
radial-gradient(circle at 1px 1px, oklch(40% 0.03 280 / 0.5) 1px, transparent 0) 0 0 / 1.25rem 1.25rem,
oklch(18% 0.018 280);
}
.feed {
width: min(100%, 30rem);
margin: 0;
border-radius: 0.9rem;
overflow: hidden;
background: oklch(24% 0.03 280);
box-shadow: 0 1rem 2.5rem -1rem oklch(0% 0 0 / 0.7);
}
.screen {
aspect-ratio: 16 / 10;
display: grid;
align-content: space-between;
padding: clamp(0.75rem, 3vw, 1.25rem);
background: linear-gradient(145deg, oklch(94% 0.03 90), oklch(88% 0.05 60));
color: oklch(28% 0.03 280);
}
.slide-title { font: 700 clamp(0.95rem, 3vw, 1.35rem)/1.1 system-ui; letter-spacing: -0.01em; }
.bars { display: flex; align-items: end; gap: 6%; height: clamp(3rem, 16vw, 8.5rem); }
.bars i {
flex: 1;
height: calc(20% + sibling-index() * 15%);
border-radius: 0.3rem 0.3rem 0 0;
background: oklch(60% 0.15 calc(sibling-index() * 45 + 150));
}
.feed figcaption { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.8rem; font-size: 0.8rem; color: var(--muted); }
.rec { width: 0.5rem; aspect-ratio: 1; border-radius: 50%; background: oklch(65% 0.22 25); }
/* ---------- lanes ----------
Size = (feed size * mult) + offset (data-⚓️-sync-width/height). Beside the
feed a lane is --lane wide and as tall as the feed; above/below it is as
wide as the feed and --lane tall. */
.lane, .zone {
--⚓️-mult-x: 0;
--⚓️-offset-x: var(--lane);
--⚓️-mult-y: 1;
--⚓️-offset-y: 0px;
}
.lane:is([data-⚓️-side="above"], [data-⚓️-side="below"]),
.zone:is([data-⚓️-side="above"], [data-⚓️-side="below"]) {
--⚓️-mult-x: 1;
--⚓️-offset-x: 0px;
--⚓️-mult-y: 0;
--⚓️-offset-y: var(--lane);
}
.lane { display: grid; place-items: center; pointer-events: none; }
.zone {
border-radius: 0.9rem;
border: 2px dashed transparent;
transition: border-color 0.2s, background-color 0.2s;
}
.stage.dragging .zone { border-color: oklch(50% 0.04 280 / 0.6); }
.stage.dragging .zone.hot { border-color: var(--accent); background: oklch(80% 0.15 160 / 0.12); }
/* ---------- the bubble ---------- */
.pip {
--size: calc(var(--lane) - 1.25rem);
pointer-events: auto;
position: relative;
display: grid;
place-items: center;
width: var(--size);
aspect-ratio: 1;
padding: 0;
border: 3px solid oklch(98% 0 0 / 0.9);
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, oklch(78% 0.14 40), oklch(55% 0.18 10));
box-shadow: 0 0.75rem 1.75rem -0.5rem oklch(0% 0 0 / 0.7);
color: oklch(99% 0 0);
font: 700 clamp(0.8rem, 2.5vw, 1rem) system-ui;
cursor: grab;
touch-action: none;
transition: translate 0.5s var(--ease), scale 0.2s var(--ease), box-shadow 0.2s;
}
.pip:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
.pip.held {
cursor: grabbing;
scale: 1.08;
box-shadow: 0 1.25rem 2.5rem -0.5rem oklch(0% 0 0 / 0.8);
transition: scale 0.2s var(--ease), box-shadow 0.2s;
}
.mic {
position: absolute;
inset: -3px;
border-radius: 50%;
border: 3px solid var(--accent);
animation: talk 1.6s ease-in-out infinite;
}
@keyframes talk {
50% { scale: 1.12; opacity: 0.2; }
}
/* ---------- X-ray ---------- */
.demo:has(#xray:checked) [data-⚓️-name] { outline: 2px dashed oklch(80% 0.12 220); outline-offset: 3px; }
.demo:has(#xray:checked) .lane { outline: 2px solid oklch(75% 0.17 330); outline-offset: -2px; }
.demo:has(#xray:checked) .lane::after {
content: "side=" attr(data-⚓️-side);
position: absolute;
inset-block-start: 0.25rem;
inset-inline-start: 0.25rem;
padding: 0 0.35rem;
border-radius: 0.3rem;
background: oklch(75% 0.17 330);
color: oklch(15% 0.02 330);
font: 600 0.7rem/1.5 ui-monospace, monospace;
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.pip, .pip.held, .zone { transition: none; }
.pip.held { scale: none; }
.mic { animation: none; }
}// Pointer logic + state only. While dragging, the bubble follows the pointer
// with a translate offset. On release we pick the nearest side of the feed
// (compare the pointer with the feed's rect), write it to data-⚓️-side, and
// drop the offset: the library moves and tweens the lane, the offset eases to 0.
const stage = document.querySelector(".stage");
const feed = document.querySelector(".feed");
const lane = document.querySelector(".lane");
const pip = document.querySelector(".pip");
const zones = [...document.querySelectorAll(".zone")];
const status = document.getElementById("status");
const SIDE = "data-⚓️-side";
const rtl = () => getComputedStyle(stage).direction === "rtl";
// Physical direction -> logical side (before/after swap in RTL).
function logical(dir) {
if (dir === "left") return rtl() ? "after" : "before";
if (dir === "right") return rtl() ? "before" : "after";
return dir === "up" ? "above" : "below";
}
function nearestSide(x, y) {
const r = feed.getBoundingClientRect();
const u = (x - (r.left + r.width / 2)) / r.width;
const v = (y - (r.top + r.height / 2)) / r.height;
if (Math.abs(u) > Math.abs(v)) return logical(u < 0 ? "left" : "right");
return logical(v < 0 ? "up" : "down");
}
function dock(side) {
lane.setAttribute(SIDE, side);
const horizontal = side === "above" || side === "below";
lane.toggleAttribute("data-⚓️-align-leading", horizontal);
lane.toggleAttribute("data-⚓️-align-top", !horizontal);
status.innerHTML = `Docked <b>${side}</b>`;
}
let start = null;
pip.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
pip.setPointerCapture(e.pointerId);
start = { x: e.clientX, y: e.clientY };
pip.classList.add("held");
stage.classList.add("dragging");
});
pip.addEventListener("pointermove", (e) => {
if (!start) return;
pip.style.translate = `${e.clientX - start.x}px ${e.clientY - start.y}px`;
const side = nearestSide(e.clientX, e.clientY);
zones.forEach((z) => z.classList.toggle("hot", z.dataset.zone === side));
});
function release(e) {
if (!start) return;
const moved = Math.hypot(e.clientX - start.x, e.clientY - start.y) > 4;
start = null;
if (moved) dock(nearestSide(e.clientX, e.clientY));
pip.classList.remove("held");
stage.classList.remove("dragging");
zones.forEach((z) => z.classList.remove("hot"));
pip.style.translate = "";
}
pip.addEventListener("pointerup", release);
pip.addEventListener("pointercancel", release);
const KEYS = { ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", ArrowDown: "down" };
pip.addEventListener("keydown", (e) => {
if (!KEYS[e.key]) return;
e.preventDefault();
dock(logical(KEYS[e.key]));
});data-⚓️-align-leadingdata-⚓️-align-topdata-⚓️-namedata-⚓️-rootdata-⚓️-sidedata-⚓️-sync-heightdata-⚓️-sync-widthdata-⚓️-todata-⚓️-transitiondata-⚓️-tween