About this snippet

A floating AI toolbar for an editor card that moves over whatever text you select, with Rewrite, Shorter, Fix grammar and Spanish actions. Drag across part of a sentence, click a sentence, or tab to one, and the bar moves above it. Picking an action streams a suggestion into a diff card that shows the current sentence struck through in red above the new wording in green. Accept swaps the suggestion into the paragraph, and Discard closes the card and keeps the sentence as it was.

On every selectionchange the script reads window.getSelection().getRangeAt(0), finds the first sentence the range touches with intersectsNode, and centers the toolbar above the range using getBoundingClientRect(), clamped so it stays inside the card. It hides during a mouse drag, so it never covers the text you are selecting. Each sentence keeps its own four drafts, and the action buttons prevent mousedown default so the selection stays active while the new wording streams in.

The toolbar uses role="toolbar" with aria-pressed on the action pills, and it comes right after the sentences in the tab order, so keyboard users can tab to a sentence and then into its actions. Escape hides the toolbar and the diff card, and if focus was inside them it moves back to the sentence. A live status region announces each finished draft and whether you accepted or discarded it. The demo starts with the last sentence targeted and its Rewrite draft open.

Size
16.7 KB
Uses
HTML, CSS, JavaScript
Loads
Nothing external
Views
1
<main class="page">
  <article class="doc" id="doc" aria-labelledby="doc-title">
    <header class="doc-head">
      <span class="doc-tag">Release note draft</span>
      <span class="doc-hint">Select or tab to a sentence</span>
    </header>
    <h1 class="doc-title" id="doc-title">Edge cache invalidation in 200 ms</h1>
    <p class="doc-body" id="editor">
      <span class="sentence" tabindex="0">Purge requests now fans out over a single persistent HTTP/3 stream per region.</span> <span class="sentence" tabindex="0">Due to the fact that each edge node keep a warm connection open at all times, cache purges finish in under 200 ms globally.</span> <span class="sentence sel" tabindex="0">Stale assets are evicted before the next client request land.</span>
    </p>
    <div class="ai-bar" id="ai-bar" role="toolbar" aria-label="AI writing actions">
      <span class="ai-spark" aria-hidden="true">
        <svg viewBox="0 0 24 24">
          <path d="M10 4Q10 12 18 12Q10 12 10 20Q10 12 2 12Q10 12 10 4Z"></path>
          <path d="M19 2Q19 5.5 22.5 5.5Q19 5.5 19 9Q19 5.5 15.5 5.5Q19 5.5 19 2Z"></path>
        </svg>
      </span>
      <button type="button" class="bar-btn" data-action="rewrite" aria-pressed="true" title="Rewrite">
        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5Z"></path></svg>
        <span>Rewrite</span>
      </button>
      <button type="button" class="bar-btn" data-action="shorter" aria-pressed="false" title="Shorter">
        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"></path></svg>
        <span>Shorter</span>
      </button>
      <button type="button" class="bar-btn" data-action="grammar" aria-pressed="false" title="Fix grammar">
        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 6L9 17l-5-5"></path></svg>
        <span>Fix grammar</span>
      </button>
      <button type="button" class="bar-btn" data-action="translate" aria-pressed="false" title="Spanish">
        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 8 6 6M4 14l6-6 2-3M2 5h12M7 2h1M22 22l-5-10-5 10M14 18h6"></path></svg>
        <span>Spanish</span>
      </button>
    </div>
    <section class="diff-card" id="diff-card" aria-label="AI suggestion">
      <div class="diff-head">
        <span class="diff-badge" id="diff-badge">
          <svg viewBox="0 0 24 24" aria-hidden="true">
            <path d="M10 4Q10 12 18 12Q10 12 10 20Q10 12 2 12Q10 12 10 4Z"></path>
            <path d="M19 2Q19 5.5 22.5 5.5Q19 5.5 19 9Q19 5.5 15.5 5.5Q19 5.5 19 2Z"></path>
          </svg>
          <span id="diff-label">Rewrite</span>
        </span>
        <div class="diff-actions">
          <button type="button" class="btn-discard" id="discard-btn">Discard</button>
          <button type="button" class="btn-accept" id="accept-btn">
            <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 6L9 17l-5-5"></path></svg>
            <span>Accept</span>
          </button>
        </div>
      </div>
      <p class="diff-old" id="diff-old">Stale assets are evicted before the next client request land.</p>
      <p class="diff-new" id="diff-new" aria-busy="false">Stale assets are removed before the next client request arrives.</p>
    </section>
    <p class="sr-only" id="status" role="status"></p>
  </article>
</main>
*, *::before, *::after {
  box-sizing: border-box;
}

:root {
  --page: #f1f5f9;
  --card: #ffffff;
  --text: #0f172a;
  --muted: #64748b;
  --soft: #f1f5f9;
  --hover: #e2e8f0;
  --accent: #2563eb;
  --accent-soft: #eff6ff;
  --accent-text: #1d4ed8;
  --bar: #0f172a;
  --bar-text: #f8fafc;
  --bar-muted: #cbd5e1;
  --sel-bg: #dbeafe;
  --sel-text: #1e3a8a;
  --del-bg: #fef2f2;
  --del-text: #b91c1c;
  --ins-bg: #f0fdf4;
  --ins-text: #15803d;
  --ring: rgba(15, 23, 42, 0.06);
  --shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 18px 40px -14px rgba(15, 23, 42, 0.14);
}

body {
  font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  margin: 0;
  background: var(--page);
  color: var(--text);
}

.page {
  display: grid;
  grid-template-columns: minmax(0, 620px);
  justify-content: center;
  align-content: start;
  min-height: 100vh;
  padding: max(24px, calc(50vh - 188px)) 16px 24px;
}

.doc {
  position: relative;
  padding: 26px 28px 24px;
  border-radius: 22px;
  background: var(--card);
  box-shadow: 0 0 0 1px var(--ring), var(--shadow);
}

.ai-bar {
  position: absolute;
  top: -22px;
  left: 50%;
  z-index: 20;
  display: flex;
  align-items: center;
  gap: 4px;
  max-width: calc(100% - 16px);
  padding: 5px 6px 5px 8px;
  overflow-x: auto;
  border-radius: 999px;
  background: var(--bar);
  color: var(--bar-text);
  box-shadow: 0 16px 36px -8px rgba(15, 23, 42, 0.4);
  transform: translateX(-50%);
  transition: left 0.18s ease, top 0.18s ease;
}

.ai-bar[hidden] {
  display: none;
}

.ai-spark {
  display: grid;
  place-items: center;
  flex-shrink: 0;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: linear-gradient(135deg, #3b82f6, #8b5cf6);
  color: #ffffff;
}

.ai-spark svg {
  width: 14px;
  height: 14px;
  fill: currentColor;
}

.bar-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  flex-shrink: 0;
  min-height: 30px;
  padding: 5px 11px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--bar-muted);
  font: inherit;
  font-size: 0.78125rem;
  font-weight: 600;
  white-space: nowrap;
  cursor: pointer;
  transition: background 0.15s ease, color 0.15s ease;
}

.bar-btn:hover {
  background: rgba(255, 255, 255, 0.1);
  color: #ffffff;
}

.bar-btn[aria-pressed="true"] {
  background: #2563eb;
  color: #ffffff;
}

.bar-btn svg {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.doc-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 8px;
}

.doc-tag {
  padding: 3px 9px;
  border-radius: 999px;
  background: var(--soft);
  color: var(--muted);
  font-size: 0.75rem;
  font-weight: 600;
}

.doc-hint {
  font-size: 0.75rem;
  color: var(--muted);
}

.doc-title {
  margin: 0 0 10px;
  font-size: 1.15rem;
  letter-spacing: -0.015em;
  line-height: 1.35;
}

.doc-body {
  margin: 0;
  font-size: 0.9375rem;
  line-height: 1.7;
}

.sentence {
  border-radius: 6px;
  cursor: text;
}

.sel {
  padding: 2px 5px;
  border-radius: 6px;
  background: var(--sel-bg);
  color: var(--sel-text);
  font-weight: 500;
  transition: background 0.2s ease, color 0.2s ease;
}

.sel.is-accepted {
  background: var(--ins-bg);
  color: var(--ins-text);
}

.diff-card {
  display: grid;
  gap: 8px;
  margin-top: 16px;
  padding: 14px 16px;
  border-radius: 16px;
  background: var(--soft);
}

.diff-card[hidden] {
  display: none;
}

.diff-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}

.diff-badge {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 3px 10px;
  border-radius: 999px;
  background: var(--accent-soft);
  color: var(--accent-text);
  font-size: 0.75rem;
  font-weight: 600;
}

.diff-badge svg {
  width: 13px;
  height: 13px;
  fill: currentColor;
}

.diff-actions {
  display: flex;
  align-items: center;
  gap: 6px;
}

.btn-discard,
.btn-accept {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  min-height: 30px;
  padding: 4px 11px;
  border: 0;
  border-radius: 999px;
  font: inherit;
  font-size: 0.75rem;
  font-weight: 600;
  cursor: pointer;
  transition: transform 0.15s ease, background 0.15s ease;
}

.btn-discard {
  background: var(--card);
  color: var(--muted);
}

.btn-discard:hover {
  background: var(--hover);
  color: var(--text);
}

.btn-accept {
  background: var(--text);
  color: var(--card);
}

.btn-accept:hover {
  transform: translateY(-1px);
}

.btn-accept svg {
  width: 13px;
  height: 13px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.diff-old,
.diff-new {
  margin: 0;
  padding: 7px 10px;
  border-radius: 10px;
  font-size: 0.84375rem;
  line-height: 1.5;
}

.diff-old {
  background: var(--del-bg);
  color: var(--del-text);
  text-decoration: line-through;
}

.diff-new {
  background: var(--ins-bg);
  color: var(--ins-text);
  font-weight: 500;
}

.sentence:focus-visible,
.bar-btn:focus-visible,
.btn-discard:focus-visible,
.btn-accept:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (max-width: 520px) {
  .page {
    padding-top: 24px;
  }

  .doc {
    padding: 24px 18px 18px;
  }

  .doc-hint {
    display: none;
  }

  .bar-btn {
    padding: 8px;
  }

  .bar-btn span {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}

@media (pointer: coarse) {
  .bar-btn,
  .btn-discard,
  .btn-accept {
    min-height: 44px;
  }

  .bar-btn {
    justify-content: center;
    min-width: 44px;
  }
}

@media (prefers-color-scheme: dark) {
  :root {
    --page: #0b1120;
    --card: #151e30;
    --text: #f1f5f9;
    --muted: #94a3b8;
    --soft: #1e293b;
    --hover: #293548;
    --accent: #3b82f6;
    --accent-soft: #172554;
    --accent-text: #93c5fd;
    --bar: #090d16;
    --sel-bg: #1e3a8a;
    --sel-text: #dbeafe;
    --del-bg: #3b1822;
    --del-text: #fca5a5;
    --ins-bg: #14352a;
    --ins-text: #86efac;
    --ring: rgba(255, 255, 255, 0.08);
    --shadow: 0 18px 40px -14px rgba(0, 0, 0, 0.6);
  }
}

@media (forced-colors: active) {
  .doc,
  .ai-bar,
  .diff-card {
    border: 1px solid CanvasText;
  }

  .bar-btn[aria-pressed="true"] {
    outline: 2px solid Highlight;
  }
}

@media (prefers-reduced-motion: reduce) {
  .ai-bar,
  .bar-btn,
  .sel,
  .btn-discard,
  .btn-accept {
    transition: none;
  }
}
const DRAFTS = [
  {
    rewrite: "Each region now receives purge requests over one persistent HTTP/3 stream.",
    shorter: "Purges now share one HTTP/3 stream per region.",
    grammar: "Purge requests now fan out over a single persistent HTTP/3 stream per region.",
    translate: "Las solicitudes de purga ahora se distribuyen a través de un único flujo HTTP/3 persistente por región."
  },
  {
    rewrite: "Because every edge node holds a warm connection open, cache purges finish globally in under 200 ms.",
    shorter: "Warm edge connections keep global cache purges under 200 ms.",
    grammar: "Due to the fact that each edge node keeps a warm connection open at all times, cache purges finish in under 200 ms globally.",
    translate: "Como cada nodo perimetral mantiene una conexión activa en todo momento, las purgas de caché terminan en menos de 200 ms en todo el mundo."
  },
  {
    rewrite: "Stale assets are removed before the next client request arrives.",
    shorter: "Stale assets clear before the next request.",
    grammar: "Stale assets are evicted before the next client request lands.",
    translate: "Los recursos obsoletos se eliminan antes de que llegue la siguiente solicitud del cliente."
  }
];

const LABELS = { rewrite: "Rewrite", shorter: "Shorter", grammar: "Fix grammar", translate: "Spanish" };

const doc = document.getElementById("doc");
const editor = document.getElementById("editor");
const sentences = Array.from(editor.querySelectorAll(".sentence"));
const bar = document.getElementById("ai-bar");
const buttons = bar.querySelectorAll(".bar-btn");
const diffCard = document.getElementById("diff-card");
const diffLabel = document.getElementById("diff-label");
const diffOld = document.getElementById("diff-old");
const diffNew = document.getElementById("diff-new");
const acceptBtn = document.getElementById("accept-btn");
const discardBtn = document.getElementById("discard-btn");
const status = document.getElementById("status");

let target = sentences[2];
let activeKey = "rewrite";
let streamTimer = 0;
let restoring = false;
let dragging = false;
let barWasShown = false;

// Center the toolbar above a rectangle, kept inside the card
function placeBar(rect) {
  bar.hidden = false;
  const box = doc.getBoundingClientRect();
  const half = bar.offsetWidth / 2;
  const center = rect.left + rect.width / 2 - box.left;
  const left = Math.min(Math.max(center, half + 8), box.width - half - 8);
  const top = Math.max(rect.top - box.top - bar.offsetHeight - 10, 8);
  bar.style.left = left + "px";
  bar.style.top = top + "px";
}

// Use the live selection when it covers the target sentence
function anchorRect() {
  const sel = window.getSelection();
  if (sel.rangeCount && !sel.isCollapsed) {
    const range = sel.getRangeAt(0);
    if (editor.contains(range.commonAncestorContainer) && range.intersectsNode(target)) return range.getBoundingClientRect();
  }
  return target.getBoundingClientRect();
}

function closeDiff() {
  clearInterval(streamTimer);
  streamTimer = 0;
  diffCard.hidden = true;
  diffNew.setAttribute("aria-busy", "false");
  buttons.forEach(function(btn) {
    btn.setAttribute("aria-pressed", "false");
  });
}

function setTarget(span) {
  if (span === target) return;
  target.classList.remove("sel", "is-accepted");
  target = span;
  target.classList.add("sel");
  closeDiff();
}

function selectSentence(span) {
  const range = document.createRange();
  range.selectNodeContents(span);
  const sel = window.getSelection();
  sel.removeAllRanges();
  sel.addRange(range);
}

// Target the first sentence the selection touches
function onSelection() {
  if (dragging) return;
  const sel = window.getSelection();
  if (!sel.rangeCount || sel.isCollapsed) return;
  const range = sel.getRangeAt(0);
  if (!editor.contains(range.commonAncestorContainer)) return;
  const span = sentences.find(function(s) {
    return range.intersectsNode(s);
  });
  if (!span) return;
  setTarget(span);
  placeBar(range.getBoundingClientRect());
}

// Stream the draft for the targeted sentence word by word
function runAction(key) {
  const words = DRAFTS[sentences.indexOf(target)][key].split(" ");
  activeKey = key;
  clearInterval(streamTimer);
  buttons.forEach(function(btn) {
    btn.setAttribute("aria-pressed", String(btn.dataset.action === key));
  });
  target.classList.remove("is-accepted");
  diffCard.hidden = false;
  diffLabel.textContent = LABELS[key];
  diffOld.textContent = target.textContent;
  diffNew.textContent = "";
  diffNew.setAttribute("aria-busy", "true");
  let i = 0;
  streamTimer = setInterval(function() {
    diffNew.textContent += (i ? " " : "") + words[i];
    i += 1;
    if (i >= words.length) {
      clearInterval(streamTimer);
      streamTimer = 0;
      diffNew.setAttribute("aria-busy", "false");
      status.textContent = LABELS[key] + " suggestion ready";
    }
  }, 35);
}

buttons.forEach(function(btn) {
  btn.addEventListener("mousedown", function(e) {
    e.preventDefault();
  });
  btn.addEventListener("click", function() {
    runAction(btn.dataset.action);
  });
});

sentences.forEach(function(span) {
  span.addEventListener("click", function() {
    if (window.getSelection().isCollapsed) selectSentence(span);
  });
  span.addEventListener("focus", function() {
    if (!restoring && span.matches(":focus-visible")) selectSentence(span);
  });
});

acceptBtn.addEventListener("click", function() {
  target.textContent = DRAFTS[sentences.indexOf(target)][activeKey];
  target.classList.add("is-accepted");
  closeDiff();
  placeBar(target.getBoundingClientRect());
  status.textContent = "Accepted " + LABELS[activeKey] + " suggestion";
});

discardBtn.addEventListener("click", function() {
  closeDiff();
  status.textContent = "Discarded suggestion";
});

document.addEventListener("selectionchange", onSelection);

// Hide the toolbar while a mouse drag selects text
editor.addEventListener("mousedown", function(e) {
  if (e.button !== 0) return;
  dragging = true;
  barWasShown = !bar.hidden;
  bar.hidden = true;
});

document.addEventListener("mouseup", function() {
  if (!dragging) return;
  dragging = false;
  onSelection();
  if (bar.hidden && barWasShown) placeBar(target.getBoundingClientRect());
});

document.addEventListener("keydown", function(e) {
  if (e.key !== "Escape" || (bar.hidden && diffCard.hidden)) return;
  const lost = bar.contains(document.activeElement) || diffCard.contains(document.activeElement);
  closeDiff();
  bar.hidden = true;
  window.getSelection().removeAllRanges();
  if (lost) {
    restoring = true;
    target.focus();
    restoring = false;
  }
  status.textContent = "Closed the AI toolbar";
});

// Keep the toolbar in place when the card changes size
new ResizeObserver(function() {
  if (!bar.hidden) placeBar(anchorRect());
}).observe(doc);

// Start with the last sentence targeted and its Rewrite open
bar.style.transition = "none";
placeBar(target.getBoundingClientRect());
bar.getBoundingClientRect();
bar.style.transition = "";
Esc
↑↓ moveEnter select

Start from a template

Open an .html file, or HTML, CSS, JS, Markdown, Less or TypeScript files. You can also drop them here. Styles and scripts go into their panes.

Share

Source

Keyboard shortcuts

Save