About this snippet

An action bar under an AI reply with the controls people expect in a chat app. Copy writes the current reply to the clipboard and swaps its icon for a check mark. The thumbs up and thumbs down buttons record a rating, and thumbs down opens a drawer with reason chips and a note box. Regenerate streams a fresh version of the reply, and the branch switcher flips between versions without losing your earlier answers.

Each reply version lives in a branches array with its own text, code snippet, rating and feedback reason, so switching between 1 / 2 and 2 / 2 restores the rating you gave that version. Clicking Regenerate appends a third branch and streams it in word by word while disabling the branch arrows until the stream finishes. The feedback drawer animates grid-template-rows between 0fr and 1fr so it opens smoothly without measuring its height.

The thumbs buttons use aria-pressed so screen readers know which rating is active, and the drawer button sets aria-expanded. When a branch button is disabled at the first or last version, focus moves to the opposite arrow so keyboard users are never stranded on a disabled control. A live status region announces clipboard copies, branch changes and submitted feedback.

Size
18.6 KB
Uses
HTML, CSS, JavaScript
Loads
Nothing external
Views
0
<main class="thread">
  <p class="msg-user">How do I format a relative time like "3 hours ago" in JavaScript without a library?</p>
  <article class="msg-ai" aria-label="Assistant reply">
    <header class="msg-head">
      <span class="msg-badge">
        <span class="msg-ico" 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>
        Nova 4
      </span>
      <span class="msg-meta">JavaScript &middot; 0.6s</span>
    </header>
    <div class="reply-body" id="reply-body" aria-busy="false"></div>
    <div class="action-bar" role="toolbar" aria-label="Reply actions">
      <div class="branch-nav" id="branch-nav" role="group" aria-label="Reply versions">
        <button type="button" class="icon-btn" id="prev-branch" aria-label="Previous version">
          <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6"></path></svg>
        </button>
        <span class="branch-count" id="branch-count">2 / 2</span>
        <button type="button" class="icon-btn" id="next-branch" aria-label="Next version">
          <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 18l6-6-6-6"></path></svg>
        </button>
      </div>
      <button type="button" class="icon-btn" id="copy-btn" aria-label="Copy reply">
        <svg class="icon-copy" viewBox="0 0 24 24" aria-hidden="true">
          <rect x="9" y="9" width="13" height="13" rx="2"></rect>
          <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
        </svg>
        <svg class="icon-check" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 6L9 17l-5-5"></path></svg>
      </button>
      <button type="button" class="icon-btn" id="up-btn" aria-label="Good response" aria-pressed="false">
        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 10v12M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"></path></svg>
      </button>
      <button type="button" class="icon-btn" id="down-btn" aria-label="Bad response" aria-pressed="false" aria-expanded="false" aria-controls="feedback-drawer">
        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17 14V2M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z"></path></svg>
      </button>
      <button type="button" class="regen-btn" id="regen-btn">
        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8M3 3v5h5M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16M16 16h5v5"></path></svg>
        <span id="regen-label">Regenerate</span>
      </button>
    </div>
    <div class="drawer" id="feedback-drawer">
      <div class="drawer-inner">
        <form class="feedback-form" id="feedback-form">
          <p class="feedback-title">What went wrong with this reply?</p>
          <div class="reasons" id="reasons" role="group" aria-label="Feedback reasons"></div>
          <label class="sr-only" for="feedback-note">Additional details</label>
          <input type="text" id="feedback-note" placeholder="Optional note">
          <div class="feedback-actions">
            <button type="button" class="btn-ghost" id="feedback-cancel">Cancel</button>
            <button type="submit" class="btn-solid">Send feedback</button>
          </div>
        </form>
      </div>
    </div>
    <p class="thanks" id="thanks" hidden></p>
    <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;
  --bubble: #e2e8f0;
  --accent: #2563eb;
  --accent-soft: #eff6ff;
  --accent-text: #1d4ed8;
  --down-soft: #fef2f2;
  --down-text: #dc2626;
  --ok: #15803d;
  --ok-soft: #dcfce7;
}

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

.thread {
  display: grid;
  grid-template-columns: minmax(0, 620px);
  justify-content: center;
  align-content: start;
  gap: 14px;
  min-height: 100vh;
  padding: max(20px, calc(50vh - 191px)) 16px 20px;
}

.msg-user {
  justify-self: end;
  max-width: 82%;
  margin: 0;
  padding: 11px 16px;
  border-radius: 20px 20px 6px 20px;
  background: var(--bubble);
  color: var(--text);
  font-size: 0.9375rem;
  line-height: 1.5;
}

.msg-ai {
  padding: 22px 24px 16px;
  border-radius: 22px;
  background: var(--card);
  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 18px 40px -14px rgba(15, 23, 42, 0.14);
}

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

.msg-badge {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-size: 0.875rem;
  font-weight: 600;
  color: var(--text);
}

.msg-ico {
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  border-radius: 8px;
  background: var(--accent-soft);
  color: var(--accent);
}

.msg-ico svg {
  width: 14px;
  height: 14px;
  fill: currentColor;
}

.msg-meta {
  font-family: ui-monospace, Consolas, monospace;
  font-size: 0.75rem;
  color: var(--muted);
}

.reply-body p {
  margin: 0 0 12px;
  line-height: 1.65;
}

.reply-body pre {
  margin: 0;
  padding: 14px 16px;
  overflow-x: auto;
  border-radius: 14px;
  background: #0f172a;
  color: #e2e8f0;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 0.8125rem;
  line-height: 1.6;
}

.action-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px;
  margin-top: 12px;
}

.branch-nav {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  margin-right: 4px;
  padding: 2px;
  border-radius: 999px;
  background: var(--soft);
}

.branch-count {
  min-width: 38px;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 0.75rem;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--text);
  text-align: center;
}

.icon-btn {
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  padding: 0;
  border: 0;
  border-radius: 9px;
  background: transparent;
  color: var(--muted);
  cursor: pointer;
  transition: background 0.15s ease, color 0.15s ease;
}

.branch-nav .icon-btn {
  width: 28px;
  height: 28px;
  border-radius: 50%;
}

.icon-btn:hover:not(:disabled) {
  background: var(--soft);
  color: var(--text);
}

.branch-nav .icon-btn:hover:not(:disabled) {
  background: var(--card);
}

.icon-btn:disabled {
  opacity: 0.35;
  cursor: default;
}

.icon-btn svg,
.regen-btn svg {
  width: 17px;
  height: 17px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.9;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.icon-check {
  display: none;
  color: var(--ok);
}

.icon-btn.is-copied .icon-copy {
  display: none;
}

.icon-btn.is-copied .icon-check {
  display: block;
}

#up-btn[aria-pressed="true"] {
  background: var(--accent-soft);
  color: var(--accent-text);
}

#down-btn[aria-pressed="true"] {
  background: var(--down-soft);
  color: var(--down-text);
}

.regen-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: 34px;
  margin-left: auto;
  padding: 5px 13px;
  border: 0;
  border-radius: 999px;
  background: var(--soft);
  color: var(--text);
  font: inherit;
  font-size: 0.8125rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.15s ease, color 0.15s ease;
}

.regen-btn:hover:not(:disabled) {
  background: var(--hover);
}

.regen-btn:disabled {
  opacity: 0.55;
  cursor: default;
}

.regen-btn.is-busy svg {
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

.drawer {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.22s ease;
}

.drawer.is-open {
  grid-template-rows: 1fr;
}

.drawer-inner {
  overflow: hidden;
}

.feedback-form {
  display: grid;
  gap: 10px;
  margin-top: 12px;
  padding: 14px;
  border-radius: 16px;
  background: var(--soft);
}

.feedback-title {
  margin: 0;
  font-size: 0.8125rem;
  font-weight: 600;
}

.reasons {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.reason-chip {
  min-height: 30px;
  padding: 4px 11px;
  border: 0;
  border-radius: 999px;
  background: var(--card);
  color: var(--text);
  font: inherit;
  font-size: 0.75rem;
  font-weight: 600;
  cursor: pointer;
}

.reason-chip[aria-pressed="true"] {
  background: var(--accent);
  color: #ffffff;
}

.feedback-form input {
  width: 100%;
  padding: 8px 11px;
  border: 0;
  border-radius: 10px;
  background: var(--card);
  color: var(--text);
  font: inherit;
  font-size: 0.8125rem;
}

.feedback-actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}

.btn-ghost,
.btn-solid {
  min-height: 32px;
  padding: 5px 13px;
  border: 0;
  border-radius: 9px;
  font: inherit;
  font-size: 0.8125rem;
  font-weight: 600;
  cursor: pointer;
}

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

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

.thanks {
  margin: 10px 0 0;
  padding: 8px 12px;
  border-radius: 10px;
  background: var(--ok-soft);
  color: var(--ok);
  font-size: 0.8125rem;
  font-weight: 600;
}

.icon-btn:focus-visible,
.regen-btn:focus-visible,
.reason-chip:focus-visible,
.btn-ghost:focus-visible,
.btn-solid:focus-visible,
.feedback-form input: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) {
  .thread {
    padding-top: 20px;
  }
}

@media (pointer: coarse) {
  .icon-btn,
  .branch-nav .icon-btn {
    width: 44px;
    height: 44px;
  }

  .regen-btn,
  .reason-chip,
  .btn-ghost,
  .btn-solid,
  .feedback-form input {
    min-height: 44px;
  }
}

@media (prefers-color-scheme: dark) {
  :root {
    --page: #0b1120;
    --card: #151e30;
    --text: #f1f5f9;
    --muted: #94a3b8;
    --soft: #1e293b;
    --hover: #293548;
    --bubble: #1e293b;
    --accent: #3b82f6;
    --accent-soft: #172554;
    --accent-text: #93c5fd;
    --down-soft: #3b1822;
    --down-text: #fca5a5;
    --ok: #86efac;
    --ok-soft: #14352a;
  }

  .reply-body pre {
    background: #090d16;
  }
}

@media (forced-colors: active) {
  .msg-ai {
    border: 1px solid CanvasText;
  }

  .icon-btn[aria-pressed="true"],
  .reason-chip[aria-pressed="true"] {
    outline: 2px solid Highlight;
  }
}

@media (prefers-reduced-motion: reduce) {
  .drawer {
    transition: none;
  }

  .regen-btn.is-busy svg {
    animation: none;
  }
}
const REASONS = ["Inaccurate", "Outdated API", "Code has a bug", "Too wordy"];

const branches = [
  {
    lead: "Subtract the timestamp from Date.now(), divide by 3,600,000 to get hours, and return a short string:",
    code: "function hoursAgo(date) {\n  const hours = Math.round((Date.now() - date) / 3600000);\n  return hours + \" hours ago\";\n}",
    rating: null,
    reason: ""
  },
  {
    lead: "Intl.RelativeTimeFormat formats relative timestamps in the user's locale without loading a date library. Setting numeric to \"auto\" also gives phrases like \"yesterday\":",
    code: "const rtf = new Intl.RelativeTimeFormat(\"en\", { numeric: \"auto\" });\nconst hours = rtf.format(-3, \"hour\"); // \"3 hours ago\"\nconst day = rtf.format(-1, \"day\");    // \"yesterday\"",
    rating: null,
    reason: ""
  }
];

const EXTRA_BRANCH = {
  lead: "If you need automatic unit selection from seconds up to days, divide through a table of cutoffs before calling Intl.RelativeTimeFormat:",
  code: "const rtf = new Intl.RelativeTimeFormat(\"en\", { numeric: \"auto\" });\nconst UNITS = [[\"day\", 86400], [\"hour\", 3600], [\"minute\", 60], [\"second\", 1]];\n\nfunction timeAgo(date) {\n  const diff = (date - Date.now()) / 1000;\n  for (const [unit, s] of UNITS) {\n    if (Math.abs(diff) >= s || unit === \"second\") {\n      return rtf.format(Math.round(diff / s), unit);\n    }\n  }\n}"
};

const replyBody = document.getElementById("reply-body");
const prevBtn = document.getElementById("prev-branch");
const nextBtn = document.getElementById("next-branch");
const countEl = document.getElementById("branch-count");
const copyBtn = document.getElementById("copy-btn");
const upBtn = document.getElementById("up-btn");
const downBtn = document.getElementById("down-btn");
const regenBtn = document.getElementById("regen-btn");
const regenLabel = document.getElementById("regen-label");
const drawer = document.getElementById("feedback-drawer");
const form = document.getElementById("feedback-form");
const reasonsEl = document.getElementById("reasons");
const noteInput = document.getElementById("feedback-note");
const cancelBtn = document.getElementById("feedback-cancel");
const thanksEl = document.getElementById("thanks");
const status = document.getElementById("status");

let current = 1;
let selectedReason = "";
let copyTimer = 0;
let streamTimer = 0;

function el(tag, className, text) {
  const node = document.createElement(tag);
  if (className) node.className = className;
  if (text) node.textContent = text;
  return node;
}

REASONS.forEach(function(label) {
  const btn = el("button", "reason-chip", label);
  btn.type = "button";
  btn.setAttribute("aria-pressed", "false");
  btn.addEventListener("click", function() {
    selectedReason = selectedReason === label ? "" : label;
    syncReasons();
  });
  reasonsEl.append(btn);
});

function syncReasons() {
  Array.from(reasonsEl.children).forEach(function(btn) {
    btn.setAttribute("aria-pressed", String(btn.textContent === selectedReason));
  });
}

function setDrawer(open) {
  drawer.classList.toggle("is-open", open);
  downBtn.setAttribute("aria-expanded", String(open));
}

function renderBranch() {
  const item = branches[current];
  replyBody.textContent = "";
  const p = el("p", "", item.lead);
  const pre = el("pre", "");
  const code = el("code", "", item.code);
  pre.append(code);
  replyBody.append(p, pre);

  countEl.textContent = (current + 1) + " / " + branches.length;
  const atStart = current === 0;
  const atEnd = current === branches.length - 1;
  prevBtn.disabled = false;
  nextBtn.disabled = false;
  if (atStart && document.activeElement === prevBtn) nextBtn.focus();
  if (atEnd && document.activeElement === nextBtn) prevBtn.focus();
  prevBtn.disabled = atStart;
  nextBtn.disabled = atEnd;

  upBtn.setAttribute("aria-pressed", String(item.rating === "up"));
  downBtn.setAttribute("aria-pressed", String(item.rating === "down"));
  selectedReason = item.reason || "";
  syncReasons();
  setDrawer(false);

  if (item.reason) {
    thanksEl.hidden = false;
    thanksEl.textContent = "Feedback saved for this version (" + item.reason + ").";
  } else {
    thanksEl.hidden = true;
  }
}

prevBtn.addEventListener("click", function() {
  if (current > 0) {
    current -= 1;
    renderBranch();
    status.textContent = "Showing version " + (current + 1) + " of " + branches.length;
  }
});

nextBtn.addEventListener("click", function() {
  if (current < branches.length - 1) {
    current += 1;
    renderBranch();
    status.textContent = "Showing version " + (current + 1) + " of " + branches.length;
  }
});

// Copy the active branch text and code to the clipboard
copyBtn.addEventListener("click", function() {
  const item = branches[current];
  const fullText = item.lead + "\n\n" + item.code;
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(fullText).catch(function() {});
  }
  copyBtn.classList.add("is-copied");
  status.textContent = "Copied reply to clipboard";
  clearTimeout(copyTimer);
  copyTimer = setTimeout(function() {
    copyBtn.classList.remove("is-copied");
  }, 1600);
});

upBtn.addEventListener("click", function() {
  const item = branches[current];
  item.rating = item.rating === "up" ? null : "up";
  item.reason = "";
  upBtn.setAttribute("aria-pressed", String(item.rating === "up"));
  downBtn.setAttribute("aria-pressed", "false");
  setDrawer(false);
  thanksEl.hidden = true;
  status.textContent = item.rating === "up" ? "Marked response as helpful" : "Cleared rating";
});

downBtn.addEventListener("click", function() {
  const item = branches[current];
  const active = item.rating === "down";
  if (active && drawer.classList.contains("is-open")) {
    item.rating = null;
    downBtn.setAttribute("aria-pressed", "false");
    setDrawer(false);
    status.textContent = "Cleared rating";
    return;
  }
  item.rating = "down";
  upBtn.setAttribute("aria-pressed", "false");
  downBtn.setAttribute("aria-pressed", "true");
  thanksEl.hidden = true;
  setDrawer(true);
  const firstChip = reasonsEl.querySelector("button");
  if (firstChip) firstChip.focus();
});

cancelBtn.addEventListener("click", function() {
  setDrawer(false);
  downBtn.focus();
});

form.addEventListener("submit", function(e) {
  e.preventDefault();
  const item = branches[current];
  const note = noteInput.value.trim();
  item.reason = selectedReason || note || "General feedback";
  noteInput.value = "";
  setDrawer(false);
  thanksEl.hidden = false;
  thanksEl.textContent = "Thanks for the feedback (" + item.reason + ").";
  status.textContent = thanksEl.textContent;
});

// Stream a new reply version word by word
regenBtn.addEventListener("click", function() {
  if (streamTimer) return;
  const fresh = {
    lead: EXTRA_BRANCH.lead,
    code: EXTRA_BRANCH.code,
    rating: null,
    reason: ""
  };
  branches.push(fresh);
  current = branches.length - 1;
  setDrawer(false);
  thanksEl.hidden = true;
  upBtn.setAttribute("aria-pressed", "false");
  downBtn.setAttribute("aria-pressed", "false");

  countEl.textContent = (current + 1) + " / " + branches.length;
  prevBtn.disabled = true;
  nextBtn.disabled = true;
  regenBtn.disabled = true;
  regenBtn.classList.add("is-busy");
  regenLabel.textContent = "Regenerating";
  replyBody.setAttribute("aria-busy", "true");
  status.textContent = "Generating version " + branches.length;

  replyBody.textContent = "";
  const p = el("p", "");
  replyBody.append(p);
  const words = fresh.lead.split(" ");
  let i = 0;

  streamTimer = setInterval(function() {
    p.textContent += (i ? " " : "") + words[i];
    i += 1;
    if (i >= words.length) {
      clearInterval(streamTimer);
      streamTimer = 0;
      const pre = el("pre", "");
      pre.append(el("code", "", fresh.code));
      replyBody.append(pre);
      replyBody.setAttribute("aria-busy", "false");
      regenBtn.disabled = false;
      regenBtn.classList.remove("is-busy");
      regenLabel.textContent = "Regenerate";
      prevBtn.disabled = false;
      status.textContent = "Version " + branches.length + " ready";
    }
  }, 45);
});

renderBranch();
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