# Chat

> Tabbed chat panel with reasoning replies and a growing composer.

- **Slug:** `chat-composer`
- **Kind:** component · **Category:** Agent
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/primitives/ChatComposer.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/chat-composer
- **All files in one bundle:** https://viewership.ai/ui/components/chat-composer.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/chat-composer.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/ui/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy this file into `src/components/viewership/chat-composer/`:

   - `ChatComposer.astro` (raw: https://viewership.ai/ui/raw/chat-composer/ChatComposer.astro.txt)

npm packages: none.

## Usage

```astro
---
import ChatComposer from "../components/viewership/chat-composer/ChatComposer.astro";
---

<ChatComposer />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import ChatComposer, { type ChatThread } from "./ChatComposer.astro";

const threads: ChatThread[] = [
  {
    label: "Reports",
    prompt: "Compare this week's sessions to last week",
    replies: [
      { label: "GA4 Pull", sub: "Analytics Data", time: "4s", body: "Pulled sessions by channel for the last three weeks." },
      { label: "Comparison", sub: "Trend Detection", time: "2s", body: "Sessions are up 12%, led by organic search and Reddit referrals." },
    ],
  },
  {
    label: "Citations",
    prompt: "Which engine cites Northwind most often?",
    replies: [
      { label: "Prompt Runs", sub: "Citation Data", time: "3s", body: "Ran 40 tracked prompts across all four engines." },
      { label: "Share", sub: "Citation Trends", time: "2s", body: "Perplexity cites Northwind in 18 of 40 prompts, ahead of Gemini." },
    ],
  },
];
---

<div class="stage">
  <ChatComposer threads={threads} placeholder="Ask about a report or tag a campaign with @" demo />
</div>

<style>
  .stage {
    display: flex;
    justify-content: center;
  }
</style>
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `threads` | `{ label: string; prompt?: string; replies?: { label; sub; time; body }[] }[]` | `[{ label: "Chat" }]` | One tab per thread, with an opening prompt and replies. |
| `active` | `number` | `0` | Initially selected tab. |
| `placeholder` | `string` | `"Ask anything"` | Composer placeholder. |
| `demo` | `boolean` | `false` | Add the user's message and replay scripted replies. Off by default; the component only emits `vs-chat-submit`. |
| `labels` | `{ tabs?, input?, send?, newChat?, history?, more?, for? }` |  | Accessible names, plus the word between a reply's sub-label and time. |

## Source

### `src/components/viewership/chat-composer/ChatComposer.astro`

```astro
---
/**
 * ChatComposer: a tabbed chat panel with reasoning replies above an
 * auto-growing composer. Tabs follow the WAI-ARIA tabs pattern; each tab is
 * its own thread.
 *
 * Ported from Beautiful UI `components/primitives/ChatComposer.tsx`
 * (MIT, © 2026 Shane Levine).
 *
 * Nothing is sent. Submit emits `vs-chat-submit` { text, tab, index } and
 * clears the composer. To render messages, dispatch `vs-chat-append` on the
 * root: { role: "user", text } or { role: "reply", label, sub, time, body }
 * (optional `index` picks a tab). With `demo`, submit also appends locally
 * and replays the active thread's scripted replies. The header buttons emit
 * `vs-chat-action` { action: "new" | "history" | "more" }.
 *
 * @example
 * <ChatComposer threads={[{ label: "Reports", prompt: "...", replies: [...] }]} />
 */
import type { HTMLAttributes } from "astro/types";

/** One agent reasoning row: "<label> <sub> for <time>" over a body line. */
export type ChatReply = { label: string; sub: string; time: string; body: string };
/** One tab: an optional opening prompt and the replies that follow it. */
export type ChatThread = { label: string; prompt?: string; replies?: ChatReply[] };

type Props = HTMLAttributes<"div"> & {
  threads?: ChatThread[];
  /** Index of the initially selected tab. */
  active?: number;
  placeholder?: string;
  /** Demo mode: append the user's message locally and replay scripted replies. */
  demo?: boolean;
  labels?: {
    tabs?: string;
    input?: string;
    send?: string;
    newChat?: string;
    history?: string;
    more?: string;
    /** Word between a reply's sub-label and its duration. */
    for?: string;
  };
};

const {
  threads = [{ label: "Chat" }],
  active = 0,
  placeholder = "Ask anything",
  demo = false,
  labels,
  class: className,
  ...rest
} = Astro.props;

const text = {
  tabs: "Conversations",
  input: "Chat prompt",
  send: "Send",
  newChat: "New chat",
  history: "History",
  more: "More options",
  for: "for",
  ...labels,
};
const uid = `vs-chat-${Math.random().toString(36).slice(2, 8)}`;
const current = Math.min(Math.max(active, 0), threads.length - 1);
---

<div class:list={["vs-chat-composer", className]} data-demo={demo ? "" : undefined} data-for={text.for} {...rest}>
  <div class="head">
    <div class="tabs" role="tablist" aria-label={text.tabs}>
      {threads.map((t, i) => (
        <button
          type="button"
          role="tab"
          class="tab"
          id={`${uid}-tab-${i}`}
          aria-controls={`${uid}-panel-${i}`}
          aria-selected={i === current ? "true" : "false"}
          tabindex={i === current ? 0 : -1}
        >
          {t.label}
        </button>
      ))}
    </div>
    <div class="actions">
      <button type="button" class="icon-btn" data-action="new" aria-label={text.newChat}>
        <svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"></path></svg>
      </button>
      <button type="button" class="icon-btn" data-action="history" aria-label={text.history}>
        <svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>
      </button>
      <button type="button" class="icon-btn" data-action="more" aria-label={text.more}>
        <svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.8"></circle><circle cx="12" cy="12" r="1.8"></circle><circle cx="19" cy="12" r="1.8"></circle></svg>
      </button>
    </div>
  </div>

  {threads.map((t, i) => (
    <div
      class="panel"
      role="tabpanel"
      id={`${uid}-panel-${i}`}
      aria-labelledby={`${uid}-tab-${i}`}
      tabindex="0"
      hidden={i !== current}
    >
      <div class="log" role="log" aria-live="polite" data-replies={JSON.stringify(t.replies ?? [])}>
        {t.prompt && (
          <div class="user">
            <div class="bubble">{t.prompt}</div>
          </div>
        )}
        {(t.replies ?? []).map((r) => (
          <div class="reply">
            <div class="reply-inner">
              <div class="reply-head">
                <span class="reply-label">{r.label}</span>
                <span class="reply-sub">{r.sub}</span>
                <span class="reply-time">{text.for} {r.time}</span>
              </div>
              <p class="reply-body">{r.body}</p>
            </div>
          </div>
        ))}
      </div>
    </div>
  ))}

  <div class="composer-wrap">
    <div class="composer">
      <textarea rows="1" placeholder={placeholder} aria-label={text.input}></textarea>
      <div class="send-row">
        <button type="button" class="send" aria-label={text.send} disabled>
          <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"></path></svg>
        </button>
      </div>
    </div>
  </div>

  <template data-tpl="user">
    <div class="user is-new"><div class="bubble"></div></div>
  </template>
  <template data-tpl="reply">
    <div class="reply is-new">
      <div class="reply-inner">
        <div class="reply-head">
          <span class="reply-label"></span>
          <span class="reply-sub"></span>
          <span class="reply-time"></span>
        </div>
        <p class="reply-body"></p>
      </div>
    </div>
  </template>
</div>

<script>
  type Reply = { label: string; sub: string; time: string; body: string };
  type AppendDetail = ({ role: "user"; text: string } | ({ role: "reply"; resolving?: boolean } & Reply)) & { index?: number };

  function init(root: HTMLElement) {
    const tabs = [...root.querySelectorAll<HTMLButtonElement>('[role="tab"]')];
    const panels = [...root.querySelectorAll<HTMLElement>('[role="tabpanel"]')];
    const composer = root.querySelector<HTMLElement>(".composer")!;
    const input = root.querySelector<HTMLTextAreaElement>("textarea")!;
    const send = root.querySelector<HTMLButtonElement>(".send")!;
    const userTpl = root.querySelector<HTMLTemplateElement>('template[data-tpl="user"]')!;
    const replyTpl = root.querySelector<HTMLTemplateElement>('template[data-tpl="reply"]')!;
    const forWord = root.dataset.for ?? "for";
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
    let timers: ReturnType<typeof setTimeout>[] = [];
    let index = Math.max(0, tabs.findIndex((t) => t.getAttribute("aria-selected") === "true"));

    /* ── tabs ── */
    const select = (next: number, focus = false) => {
      index = (next + tabs.length) % tabs.length;
      tabs.forEach((tab, i) => {
        const on = i === index;
        tab.setAttribute("aria-selected", String(on));
        tab.tabIndex = on ? 0 : -1;
        panels[i].hidden = !on;
      });
      if (focus) tabs[index].focus();
    };
    tabs.forEach((tab, i) => {
      tab.addEventListener("click", () => select(i));
      tab.addEventListener("keydown", (event) => {
        const moves: Record<string, number> = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 };
        if (!(event.key in moves)) return;
        event.preventDefault();
        select(moves[event.key], true);
      });
    });

    root.querySelectorAll<HTMLButtonElement>("[data-action]").forEach((button) =>
      button.addEventListener("click", () =>
        root.dispatchEvent(new CustomEvent("vs-chat-action", { bubbles: true, detail: { action: button.dataset.action } })),
      ),
    );

    /* ── messages ── */
    const scrollDown = (panel: HTMLElement) =>
      panel.scrollTo({ top: panel.scrollHeight, behavior: reduce.matches ? "auto" : "smooth" });

    const append = (detail: AppendDetail) => {
      const panel = panels[detail.index ?? index];
      const log = panel?.querySelector<HTMLElement>(".log");
      if (!panel || !log) return null;
      let node: HTMLElement;
      if (detail.role === "user") {
        node = (userTpl.content.firstElementChild as HTMLElement).cloneNode(true) as HTMLElement;
        node.querySelector(".bubble")!.textContent = detail.text;
      } else {
        node = (replyTpl.content.firstElementChild as HTMLElement).cloneNode(true) as HTMLElement;
        node.querySelector(".reply-label")!.textContent = detail.label;
        node.querySelector(".reply-sub")!.textContent = detail.sub;
        node.querySelector(".reply-time")!.textContent = `${forWord} ${detail.time}`;
        node.querySelector(".reply-body")!.textContent = detail.body;
        if (detail.resolving) node.classList.add("is-resolving");
      }
      log.append(node);
      scrollDown(panel);
      return node;
    };
    root.addEventListener("vs-chat-append", (event) => {
      const detail = (event as CustomEvent<AppendDetail>).detail;
      if (detail) append(detail);
    });

    /* demo: replay the thread's scripted replies */
    const replay = (target: number) => {
      const log = panels[target].querySelector<HTMLElement>(".log")!;
      const replies = JSON.parse(log.dataset.replies || "[]") as Reply[];
      if (reduce.matches) {
        replies.forEach((r) => append({ role: "reply", ...r, index: target }));
        return;
      }
      let at = 500;
      replies.forEach((r, i) => {
        const last = i === replies.length - 1 && replies.length > 1;
        timers.push(
          setTimeout(() => {
            const node = append({ role: "reply", ...r, index: target, resolving: last });
            if (node && last) timers.push(setTimeout(() => node.classList.remove("is-resolving"), 1200));
          }, at),
        );
        at += 1400;
      });
    };

    /* ── composer ── */
    const grow = () => {
      if (CSS.supports("field-sizing", "content")) return;
      input.style.height = "auto";
      input.style.height = `${input.scrollHeight}px`;
    };
    const sync = () => {
      send.disabled = input.value.trim().length === 0;
      grow();
    };
    const submit = () => {
      const value = input.value.trim();
      if (!value) return;
      root.dispatchEvent(
        new CustomEvent("vs-chat-submit", { bubbles: true, detail: { text: value, tab: tabs[index]?.textContent?.trim() ?? "", index } }),
      );
      input.value = "";
      sync();
      if ("demo" in root.dataset) {
        timers.forEach(clearTimeout);
        timers = [];
        append({ role: "user", text: value });
        replay(index);
      }
    };

    input.addEventListener("input", sync);
    input.addEventListener("keydown", (event) => {
      if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
        event.preventDefault();
        submit();
      }
    });
    send.addEventListener("click", submit);
    composer.addEventListener("click", (event) => {
      if (!(event.target as Element).closest("button")) input.focus();
    });
    document.addEventListener("astro:before-swap", () => timers.forEach(clearTimeout), { once: true });
    sync();
  }

  const boot = () =>
    document.querySelectorAll<HTMLElement>(".vs-chat-composer:not([data-vs-ready])").forEach((root) => {
      root.dataset.vsReady = "";
      init(root);
    });
  boot();
  document.addEventListener("astro:page-load", boot);
</script>

<style>
  .vs-chat-composer {
    display: flex;
    flex-direction: column;
    align-self: flex-start;
    width: 100%;
    max-width: 380px;
    height: 288px;
    overflow: hidden;
    border-radius: 14px;
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-card);
  }

  /* header: tabs + actions */
  .head {
    display: flex;
    flex-shrink: 0;
    align-items: center;
    justify-content: space-between;
    padding: 6px;
    border-bottom: 1px solid var(--vs-line);
  }
  .tabs {
    display: flex;
    align-items: center;
  }
  .tab {
    padding: 3px 8px;
    border: none;
    border-radius: 6px;
    background: transparent;
    color: var(--vs-ink);
    font: inherit;
    font-size: 13px;
    line-height: 1.5;
    opacity: 0.5;
    cursor: pointer;
    transition:
      background-color 100ms,
      opacity 100ms;
  }
  .tab:hover {
    opacity: 0.75;
  }
  .tab[aria-selected="true"] {
    background: var(--vs-field);
    opacity: 1;
  }
  .actions {
    display: flex;
    align-items: center;
    gap: 4px;
  }
  .icon-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 24px;
    height: 24px;
    padding: 0;
    border: none;
    border-radius: 6px;
    background: transparent;
    color: var(--vs-ink-3);
    cursor: pointer;
    transition:
      background-color 100ms,
      color 100ms;
  }
  .icon-btn:hover {
    background: var(--vs-hover);
    color: var(--vs-ink-2);
  }
  :is(.tab, .icon-btn, .send):focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }

  /* conversation: fixed region so the card never changes shape */
  .panel {
    display: flex;
    flex: 1;
    flex-direction: column;
    min-height: 0;
    padding: 10px 12px 4px;
    overflow-y: auto;
  }
  .panel[hidden] {
    display: none;
  }
  .panel:focus-visible {
    box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--vs-accent) 70%, transparent);
  }
  .log {
    display: flex;
    flex-direction: column;
    gap: 10px;
  }

  /* user bubble: right aligned, soft block */
  .user {
    display: flex;
    justify-content: flex-end;
    padding-left: 56px;
  }
  .bubble {
    padding: 6px 12px;
    border-radius: 12px;
    background: var(--vs-field);
    font-size: 13px;
    line-height: 1.4;
    color: var(--vs-ink);
    white-space: pre-wrap;
    overflow-wrap: anywhere;
  }
  .user.is-new {
    animation: vs-chat-composer-rise 300ms var(--vs-ease-out) both;
  }

  /* reasoning reply: the outer element enters, the inner one resolves
     (separate elements so the entrance fill does not mask the dim) */
  .reply-inner {
    display: flex;
    flex-direction: column;
    gap: 6px;
    width: 100%;
    transform-origin: top left;
    transition:
      opacity 400ms var(--vs-ease-out),
      filter 400ms var(--vs-ease-out),
      transform 400ms var(--vs-ease-out);
  }
  .reply.is-new {
    animation: vs-fade-up 400ms var(--vs-ease-out) both;
  }
  .reply.is-resolving .reply-inner {
    opacity: 0.55;
    filter: blur(0.5px);
    transform: scale(0.985);
  }
  .reply-head {
    display: flex;
    align-items: center;
    gap: 4px;
    font-size: 12px;
    line-height: 1.3;
  }
  .reply-label {
    font-weight: 500;
    color: var(--vs-ink);
  }
  .reply-sub {
    color: var(--vs-ink-2);
  }
  .reply-time {
    color: var(--vs-ink);
  }
  .reply-body {
    margin: 0;
    font-size: 13px;
    line-height: 1.5;
    color: var(--vs-ink);
  }

  /* composer */
  .composer-wrap {
    flex-shrink: 0;
    margin-top: auto;
    padding: 6px;
  }
  .composer {
    display: flex;
    flex-direction: column;
    gap: 8px;
    padding: 10px;
    border: 1px solid var(--vs-line);
    border-radius: var(--vs-radius-control);
    background: var(--vs-field);
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.035);
    cursor: text;
    transition:
      border-color 150ms,
      box-shadow 150ms;
  }
  .composer:focus-within {
    border-color: var(--vs-line-strong);
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.025);
  }
  .composer:has(textarea:focus-visible) {
    box-shadow: var(--vs-focus-ring);
  }
  textarea {
    display: block;
    width: 100%;
    min-height: 18px;
    max-height: 92px;
    margin: 0;
    padding: 0;
    border: none;
    background: transparent;
    color: var(--vs-ink);
    font: inherit;
    font-size: 13px;
    line-height: 1.4;
    resize: none;
    outline: none;
    overflow-y: auto;
    field-sizing: content;
  }
  textarea::placeholder {
    color: var(--vs-ink-3);
  }
  .send-row {
    display: flex;
    align-items: center;
    justify-content: flex-end;
  }
  .send {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 28px;
    height: 28px;
    padding: 0;
    border: none;
    border-radius: 8px;
    background: var(--vs-ink);
    color: var(--vs-surface);
    cursor: pointer;
    transition:
      background-color 200ms,
      color 200ms,
      transform 200ms;
  }
  .send:enabled:active {
    transform: scale(0.96);
  }
  .send:disabled {
    background: var(--vs-line-strong);
    color: var(--vs-ink-2);
    cursor: default;
  }

  @keyframes vs-chat-composer-rise {
    from {
      opacity: 0;
      transform: translateY(10px);
    }
    to {
      opacity: 1;
      transform: translateY(0);
    }
  }

  @media (prefers-reduced-motion: reduce) {
    .user.is-new,
    .reply.is-new {
      animation: none;
    }
    .reply.is-resolving .reply-inner {
      opacity: 1;
      filter: none;
      transform: none;
    }
    .send:enabled:active {
      transform: none;
    }
  }
</style>
```
