Chat sides
Chat bubbles placed with data-⚓️-side="before|after" around dots on a centre spine.
<main class="app">
<header class="bar">
<div class="intro">
<h1>Chat sides</h1>
<p>
Each bubble is anchored to its own dot on the centre spine with
<code>data-⚓️-side="before"</code> (them) or <code>"after"</code> (you).
<em>Swap seats</em> flips one value per bubble and <code>data-⚓️-tween</code>
swings it across. New messages grow out of the spine (<code>data-⚓️-morph-from</code>).
</p>
</div>
<div class="controls">
<button id="swap" type="button" aria-pressed="false">Swap seats</button>
<label class="xray"><input type="checkbox" id="xray"> X-ray</label>
</div>
</header>
<section class="chat" id="chat" aria-label="Conversation between Riley and Sam">
<div class="names" aria-hidden="true">
<span class="name them" id="name-before">Riley</span>
<span class="name me" id="name-after">Sam (you)</span>
</div>
<ol class="log" id="log" data-⚓️-root aria-live="polite">
<li class="row" data-from="riley" data-msg="did you see the anchor positioning thing" data-⚓️-scope-name="--node">
<span class="node" data-⚓️-name="--node"></span>
<p class="bubble" data-⚓️-to="--node" data-⚓️-side="before" data-⚓️-align-top data-⚓️-tween data-⚓️-transition="morph"><span class="who">Riley: </span>did you see the anchor positioning thing</p>
</li>
<li class="row" data-from="sam" data-msg="the one where a bubble just says which side it lives on?" data-⚓️-scope-name="--node">
<span class="node" data-⚓️-name="--node"></span>
<p class="bubble" data-⚓️-to="--node" data-⚓️-side="after" data-⚓️-align-top data-⚓️-tween data-⚓️-transition="morph"><span class="who">Sam: </span>the one where a bubble just says which side it lives on?</p>
</li>
<li class="row" data-from="riley" data-msg="yes. no flex-direction, no margins, no JS maths" data-⚓️-scope-name="--node">
<span class="node" data-⚓️-name="--node"></span>
<p class="bubble" data-⚓️-to="--node" data-⚓️-side="before" data-⚓️-align-top data-⚓️-tween data-⚓️-transition="morph"><span class="who">Riley: </span>yes. no flex-direction, no margins, no JS maths</p>
</li>
<!-- The slot after the last message. The typing indicator is anchored here,
so it glides down as messages arrive. -->
<li class="row slot" aria-hidden="true">
<span class="node" data-⚓️-name="--next"></span>
</li>
<li class="typing" id="typing" data-state="idle" aria-hidden="true"
data-⚓️-to="--next" data-⚓️-side="after" data-⚓️-align-top data-⚓️-tween data-⚓️-transition="morph">
<span></span><span></span><span></span>
</li>
</ol>
</section>
<form class="composer" id="composer">
<label class="visually-hidden" for="msg">Message</label>
<input id="msg" autocomplete="off" placeholder="Say something as Sam…" maxlength="120">
<button type="submit">Send</button>
</form>
</main>/* Demo styling only. Every bubble's position comes from data-⚓️-side /
data-⚓️-align-top relative to its spine dot; nothing here places a bubble. */
:root {
color-scheme: dark;
--bg: oklch(16% 0.012 265);
--panel: oklch(20% 0.014 265);
--line: oklch(32% 0.02 265);
--text: oklch(92% 0.01 265);
--muted: oklch(68% 0.02 265);
--riley: oklch(72% 0.13 200);
--sam: oklch(74% 0.14 330);
--spine-gap: 0.85rem;
--bw: min(19rem, calc(50% - var(--spine-gap) - 0.75rem));
background: var(--bg);
color: var(--text);
font: 400 1rem/1.45 system-ui, sans-serif;
}
*, ::before, ::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100dvh;
display: grid;
padding: clamp(0.75rem, 3vw, 2rem);
}
.app {
inline-size: min(100%, 52rem);
margin-inline: auto;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 1rem;
block-size: calc(100dvh - 2 * clamp(0.75rem, 3vw, 2rem));
}
.bar {
display: flex;
flex-wrap: wrap;
align-items: end;
justify-content: space-between;
gap: 0.75rem 1.5rem;
}
.intro { max-inline-size: 60ch; }
h1 { margin: 0 0 0.25rem; font-size: 1.4rem; letter-spacing: -0.01em; }
.intro p { margin: 0; color: var(--muted); font-size: 0.92rem; }
code { font: 0.85em ui-monospace, monospace; color: oklch(84% 0.09 200); }
.controls { display: flex; gap: 0.75rem; align-items: center; }
button {
font: inherit;
font-weight: 600;
color: var(--text);
background: oklch(28% 0.03 265);
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.45rem 1rem;
cursor: pointer;
}
button:hover { background: oklch(32% 0.04 265); }
button:focus-visible, input:focus-visible { outline: 2px solid var(--sam); outline-offset: 2px; }
.xray { display: inline-flex; gap: 0.4rem; align-items: center; color: var(--muted); font-size: 0.9rem; cursor: pointer; }
/* ---------- conversation ---------- */
.chat {
position: relative;
overflow: auto;
overscroll-behavior: contain;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 1.25rem;
padding: 0 0.75rem 1.5rem;
/* the spine */
background-image: linear-gradient(var(--line), var(--line));
background-size: 2px 100%;
background-position: center;
background-repeat: no-repeat;
background-attachment: local;
}
.names {
position: sticky;
inset-block-start: 0;
z-index: 2;
display: grid;
grid-template-columns: 1fr 1fr;
gap: calc(2 * var(--spine-gap));
padding-block: 0.75rem;
margin-inline: -0.75rem;
padding-inline: 0.75rem;
background: linear-gradient(var(--panel) 70%, transparent);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.names .them { text-align: end; }
.name[data-person="riley"] { color: var(--riley); }
.name[data-person="sam"] { color: var(--sam); }
.log {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
/* A row is the in-flow part of a message. Its height comes from an
invisible copy of the text at the bubble's max width (the bubble itself
is absolutely positioned, so it can't size the row). */
.row {
position: relative;
display: grid;
justify-items: center;
}
.row::before {
content: attr(data-msg);
visibility: hidden;
inline-size: var(--bw);
padding: 0.55rem 0.9rem;
border: 1px solid transparent; /* match the bubble box exactly */
}
/* The anchor: a dot on the spine, as tall as the row. */
.node {
position: absolute;
inset-block: 0;
inset-inline-start: 50%;
inline-size: calc(2 * var(--spine-gap));
translate: -50% 0;
}
.node::after {
content: "";
position: absolute;
inset-block-start: 0.85rem;
inset-inline-start: 50%;
translate: -50% 0;
inline-size: 0.55rem;
aspect-ratio: 1;
border-radius: 50%;
background: var(--dot, var(--line));
box-shadow: 0 0 0 3px var(--panel);
}
.row[data-from="riley"] { --dot: var(--riley); }
.row[data-from="sam"] { --dot: var(--sam); }
.slot { block-size: 2.4rem; }
.slot::before { content: none; }
.slot .node::after { content: none; }
.bubble {
margin: 0;
max-inline-size: var(--bw);
inline-size: max-content;
padding: 0.55rem 0.9rem;
border-radius: 1.1rem;
background: oklch(from var(--c) 30% calc(c * 0.45) h);
border: 1px solid oklch(from var(--c) 45% calc(c * 0.6) h);
color: oklch(from var(--c) 95% 0.03 h);
overflow-wrap: anywhere;
}
.row[data-from="riley"] .bubble { --c: var(--riley); }
.row[data-from="sam"] .bubble { --c: var(--sam); }
/* the tail corner points at the spine, whichever side the bubble is on */
.bubble[data-⚓️-side="before"] { border-start-end-radius: 0.3rem; }
.bubble[data-⚓️-side="after"] { border-start-start-radius: 0.3rem; }
.who,
.visually-hidden {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* ---------- typing indicator ---------- */
.typing {
display: flex;
gap: 0.3rem;
padding: 0.7rem 0.9rem;
border-radius: 1.1rem;
background: oklch(from var(--c, var(--sam)) 30% calc(c * 0.45) h);
border: 1px solid oklch(from var(--c, var(--sam)) 45% calc(c * 0.6) h);
}
.typing[data-person="riley"] { --c: var(--riley); }
.typing[data-person="sam"] { --c: var(--sam); }
.typing[data-state="idle"] { opacity: 0; }
.typing span {
inline-size: 0.45rem;
aspect-ratio: 1;
border-radius: 50%;
background: oklch(from var(--c, var(--sam)) 85% 0.08 h);
animation: bob 1s ease-in-out infinite;
}
.typing span:nth-child(2) { animation-delay: 0.15s; }
.typing span:nth-child(3) { animation-delay: 0.3s; }
@keyframes bob {
0%, 60%, 100% { translate: 0 0; opacity: 0.5; }
30% { translate: 0 -0.25rem; opacity: 1; }
}
/* ---------- composer ---------- */
.composer { display: flex; gap: 0.5rem; }
.composer input {
flex: 1;
min-inline-size: 0;
font: inherit;
color: var(--text);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.55rem 1rem;
}
/* ---------- X-ray: outline anchors, label each bubble's live side ---------- */
body:has(#xray:checked) [data-⚓️-name] {
outline: 1px dashed oklch(80% 0.15 90);
outline-offset: -1px;
}
body:has(#xray:checked) :is(.bubble, .typing) {
outline: 1px solid oklch(80% 0.15 90 / 0.6);
}
body:has(#xray:checked) :is(.bubble, .typing)::after {
content: "side=" attr(data-⚓️-side);
position: absolute;
inset-block-end: 100%;
inset-inline-start: 0;
font: 600 0.62rem/1.6 ui-monospace, monospace;
color: oklch(85% 0.15 90);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.typing span { animation: none; opacity: 0.8; }
}// State only: who "you" are, who's typing, which messages exist.
// Where anything sits is data-⚓️-side="before|after" around a spine dot.
const SIDE = "data-⚓️-side";
const log = document.getElementById("log");
const slot = log.querySelector(".slot");
const typing = document.getElementById("typing");
const chat = document.getElementById("chat");
const swapBtn = document.getElementById("swap");
const input = document.getElementById("msg");
let you = "sam";
const other = () => (you === "sam" ? "riley" : "sam");
const label = { riley: "Riley", sam: "Sam" };
const sideOf = (person) => (person === you ? "after" : "before");
const script = [
["sam", "ok but what happens in RTL"],
["riley", "before/after are logical, so it mirrors for free"],
["sam", "and the typing dots?"],
["riley", "anchored to the slot after the last message. watch them glide"],
["sam", "hit swap seats, I want to see the bubbles swing"],
["riley", "one attribute value per bubble. that's the whole trick"],
];
function applySides() {
for (const row of log.querySelectorAll(".row[data-from]")) {
row.querySelector(".bubble").setAttribute(SIDE, sideOf(row.dataset.from));
}
if (typing.dataset.person) typing.setAttribute(SIDE, sideOf(typing.dataset.person));
const before = document.getElementById("name-before");
const after = document.getElementById("name-after");
before.textContent = label[other()];
before.dataset.person = other();
after.textContent = `${label[you]} (you)`;
after.dataset.person = you;
input.placeholder = `Say something as ${label[you]}…`;
}
function addMessage(from, text) {
const row = document.createElement("li");
row.className = "row";
row.dataset.from = from;
row.dataset.msg = text;
row.setAttribute("data-⚓️-scope-name", "--node");
row.innerHTML = `<span class="node" data-⚓️-name="--node"></span>`;
const bubble = document.createElement("p");
bubble.className = "bubble";
for (const [k, v] of [["to", "--node"], ["side", sideOf(from)], ["align-top", ""], ["tween", ""], ["transition", "morph"], ["morph-from", ""]]) {
bubble.setAttribute(`data-⚓️-${k}`, v);
}
bubble.innerHTML = `<span class="who">${label[from]}: </span>`;
bubble.append(text);
row.append(bubble);
log.insertBefore(row, slot);
// keep the demo light
const rows = log.querySelectorAll(".row[data-from]");
if (rows.length > 24) rows[0].remove();
slot.scrollIntoView({ block: "nearest", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
}
function setTyping(person) {
if (!person) {
typing.dataset.state = "idle";
return;
}
typing.dataset.person = person;
typing.setAttribute(SIDE, sideOf(person));
typing.dataset.state = "typing";
}
swapBtn.addEventListener("click", () => {
you = other();
swapBtn.setAttribute("aria-pressed", String(you === "riley"));
applySides();
});
document.getElementById("composer").addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
addMessage(you, text);
input.value = "";
});
// scripted conversation on a loop
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => {
applySides();
for (let i = 0; ; i = (i + 1) % script.length) {
const [from, text] = script[i];
await wait(900);
setTyping(from);
await wait(1400 + text.length * 18);
setTyping(null);
addMessage(from, text);
await wait(1200);
}
})();data-⚓️-align-topdata-⚓️-morph-fromdata-⚓️-namedata-⚓️-rootdata-⚓️-scope-namedata-⚓️-sidedata-⚓️-todata-⚓️-transitiondata-⚓️-tween