# ChatWidget

> Floating customer chat for a website with a launcher and unread badge, a chat panel with greeting, quick questions, messages, typing and file attachments, and an offline leave-a-message form, driven by async callbacks.

Source: https://docs.nasaqui.com/components/chat-widget

## Install

```bash
npx shadcn@latest add https://docs.nasaqui.com/r/chat-widget.json
```

The chat bubble on a customer site. A round launcher sits in a bottom corner. It opens a panel with a greeting from
your team, the conversation, and a box to write in. Outside business hours it swaps the box for a short form so the
visitor can leave their email. It stores nothing: you pass `messages` and it calls `onSend` and `onOfflineSubmit`.
The agent side is the [`Inbox`](https://docs.nasaqui.com/components/inbox).

## When to use

- A marketing site, docs or a web app where visitors talk to your team.
- A place where you want a quick-questions list before the visitor types.

## When not to use

- The agent screen: use [`Inbox`](https://docs.nasaqui.com/components/inbox).
- An AI assistant with sources and tools: use [`CopilotChat`](https://docs.nasaqui.com/components/copilot-chat).

## Import

```tsx
import { ChatWidget } from "@fadymondy/nasaq/web";
// inside this monorepo: "@nasaq/web"
```

## Quick start

```tsx
import { ChatWidget, type WidgetMessage } from "@fadymondy/nasaq/web";
import { useState } from "react";

declare const api: { send(text: string, files: File[]): Promise<void> };

export function SiteChat() {
  const [messages, setMessages] = useState<WidgetMessage[]>([]);
  return (
    <ChatWidget
      messages={messages}
      agent={{ name: "Layla" }}
      starters={["Pricing", "Talk to sales"]}
      onSend={async (text, files) => {
        setMessages((m) => [...m, { id: crypto.randomUUID(), from: "visitor", text, at: Date.now() }]);
        await api.send(text, files);
      }}
    />
  );
}
```

## Anatomy

```
ChatWidget                       data-slot="chat-widget" (data-open, data-online)
├─ panel                         role="dialog", labelled by the title
│  ├─ header                     avatar, title, online dot with text, close
│  ├─ ChatThread                 greeting, ChatMessage rows, quick questions, TypingIndicator
│  ├─ ChatComposer               text, attached file chips, attach button (online)
│  └─ offline form               data-slot="chat-widget-offline": name, email, message; then a thank-you
└─ launcher                      round button with an unread badge
```

## API

**ChatWidget**: every `div` prop except `children` and `title`, plus:

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `messages` | `readonly WidgetMessage[]` | required | `{ id, from: "visitor" \| "agent" \| "bot", text, at, name?, avatar?, status?, attachments? }`. |
| `onSend` | `(text, files: File[]) => Promise<void \| { error? }>` | required | Send. An `error` restores the text and files and shows the message. |
| `onRetry` | `(id) => void` | | Shows Retry on a failed visitor message. |
| `open` / `defaultOpen` / `onOpenChange` | `boolean` | closed | Controlled or uncontrolled. |
| `title` / `subtitle` | `string` | "Chat with us" | Header text. The subtitle defaults to a reply-time line for the state. |
| `agent` | `{ name, avatar? }` | | Shown in the header and on the greeting. |
| `greeting` | `string` | "Hi! How can we help you today?" | First bubble. |
| `starters` | `readonly string[]` | none | Quick questions until the visitor writes. |
| `online` | `boolean` | `true` | `false` shows the offline form instead of the composer. |
| `onOfflineSubmit` | `(form: { name, email, message }) => Promise<void \| { error? }>` | | Needed for the offline form. |
| `unread` | `number` | `0` | Badge on the launcher while closed. |
| `typing` | `boolean` | `false` | The team is typing. |
| `position` | `"end" \| "start"` | `"end"` | Bottom corner (follows RTL). |
| `placement` | `"fixed" \| "absolute" \| "static"` | `"fixed"` | Pin to the viewport, to a relative parent, or not at all. |
| `maxFileMb` | `number` | `5` | Larger files are refused with a message. |
| `labels` | `Partial<ChatWidgetLabels>` | | Override any string. |

## Examples

**Outside business hours**

```tsx
import { ChatWidget } from "@fadymondy/nasaq/web";

export const Away = () => (
  <ChatWidget
    messages={[]}
    online={false}
    onSend={async () => {}}
    onOfflineSubmit={async (form) => {
      await fetch("/api/leads", { method: "POST", body: JSON.stringify(form) });
    }}
  />
);
```

## Accessibility

- The launcher is a button with `aria-expanded`. The panel is a labelled `dialog` that does not trap focus, so the page stays usable. Escape closes it and focus returns to the launcher.
- The message list is a `role="log"`. New messages are announced politely. The unread count is text, not only a badge.
- Online state is text ("Online") as well as a dot. Form errors are text under each field; send errors are `role="alert"`.

## RTL & i18n

- English and Arabic follow the Nasaq locale. `position="end"` puts the widget in the left corner in Arabic.
- The email field is left to right. The launcher icon mirrors.

## Styling & tokens

- Built on `ChatThread`, `ChatMessage`, `ChatComposer`, `Field` and `--nq-*` tokens. The header uses `bg-primary`.
- Target `[data-slot="chat-widget"]`, `[data-slot="chat-widget-offline"]`.

## Do / Don't

- Do validate the offline form on your server as well.
- Do keep the greeting to one or two lines.
- Don't auto open the panel on page load.
- Don't put personal data in the quick questions.

## Related

- [`Inbox`](https://docs.nasaqui.com/components/inbox)
- [`Chat`](https://docs.nasaqui.com/components/chat)

## Code

### React

```tsx
import { ChatWidget, type WidgetMessage } from "@fadymondy/nasaq/web";
import { useState } from "react";

declare const api: { send(text: string, files: File[]): Promise<void> };

export function SiteChat() {
  const [messages, setMessages] = useState<WidgetMessage[]>([]);
  return (
    <ChatWidget
      messages={messages}
      agent={{ name: "Layla" }}
      starters={["Pricing", "Talk to sales"]}
      onSend={async (text, files) => {
        setMessages((m) => [...m, { id: crypto.randomUUID(), from: "visitor", text, at: Date.now() }]);
        await api.send(text, files);
      }}
    />
  );
}
```

### shadcn

```tsx
import { ChatWidget, type WidgetMessage } from "@/components/ui/chat-widget";
import { useState } from "react";

declare const api: { send(text: string, files: File[]): Promise<void> };

export function SiteChat() {
  const [messages, setMessages] = useState<WidgetMessage[]>([]);
  return (
    <ChatWidget
      messages={messages}
      agent={{ name: "Layla" }}
      starters={["Pricing", "Talk to sales"]}
      onSend={async (text, files) => {
        setMessages((m) => [...m, { id: crypto.randomUUID(), from: "visitor", text, at: Date.now() }]);
        await api.send(text, files);
      }}
    />
  );
}
```

### Vue

```vue
<script setup lang="ts">
import { NqChatWidget, type WidgetMessage } from "@fadymondy/nasaq/vue";
import { ref } from "vue";

const messages = ref<WidgetMessage[]>([]);

async function send(text: string, _files: File[]) {
  messages.value.push({ id: String(messages.value.length + 1), from: "visitor", text, at: Date.now() });
}
</script>

<template>
  <div class="relative h-[34rem] w-full">
    <NqChatWidget placement="absolute" default-open :messages="messages" :agent="{ name: 'Layla' }" :starters="['Pricing', 'Talk to sales']" :on-send="send" />
  </div>
</template>
```

### Blade

```blade
<div class="relative h-[34rem] w-full">
    <x-nq::chat-widget placement="absolute" open :agent="['name' => 'Layla']" :starters="['Pricing', 'Talk to sales']" />
</div>
```

### HTML + Alpine

```html
<div class="relative h-[34rem] w-full">
    <div data-slot="chat-widget" x-data="nqChatWidget(JSON.parse('{\u0022open\u0022:true,\u0022maxFileMb\u0022:5,\u0022visitor\u0022:0,\u0022labels\u0022:{\u0022launcher\u0022:\u0022Open chat\u0022,\u0022close\u0022:\u0022Close chat\u0022,\u0022sendFailed\u0022:\u0022Could not send. Try again.\u0022,\u0022fileTooBig\u0022:\u0022{name} is larger than {mb} MB\u0022,\u0022removeFile\u0022:\u0022Remove {name}\u0022,\u0022formRequired\u0022:\u0022Required\u0022,\u0022formInvalidEmail\u0022:\u0022Enter a valid email address\u0022}}'))" x-modelable="open"
    x-bind:data-open="open ? '' : null" x-on:keydown="onKey($event)"
     data-online     class="z-50 flex flex-col items-end gap-3 absolute bottom-4 end-4">
    <section role="dialog" aria-labelledby="nq-cw-000001-title" x-show="open"         class="flex h-[min(34rem,calc(100dvh-7rem))] w-[min(24rem,calc(100vw-2rem))] flex-col overflow-hidden rounded-floating border border-border bg-card shadow-floating">
        <header class="flex items-center gap-3 bg-primary px-4 py-3 text-primary-foreground">
            <span data-slot="avatar" x-data="nqAvatar(0)"
    class="inline-flex shrink-0 select-none items-center justify-center overflow-hidden bg-secondary align-middle font-medium text-secondary-foreground size-8 text-caption rounded-full">
                    <span data-slot="avatar-fallback" class="flex size-full items-center justify-center" x-bind="fallback"
             role="img" aria-label="Layla" >L</span>
    </span>
            <div class="flex min-w-0 flex-1 flex-col">
                <h2 id="nq-cw-000001-title" dir="auto" class="truncate text-label">Chat with us</h2>
                <p class="flex items-center gap-1.5 truncate text-caption opacity-90">
                    <span aria-hidden="true" class="size-2 shrink-0 rounded-full bg-nq-success"></span>
                    <span class="sr-only">Online. </span>
                    <span dir="auto" class="truncate">We usually reply in a few minutes</span>
                </p>
            </div>
            <button data-slot="button"
     type="button"                         x-on:click="close()" aria-label="Close chat" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 size-control-sm p-0 text-primary-foreground hover:bg-primary-foreground/15">
        <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M18 6 6 18"/>
  <path d="m6 6 12 12"/>
</svg></button>
        </header>
        <div data-slot="chat-thread" x-data="nqChatThread" x-on:nq-retry.stop="retry($event)" class="relative flex min-h-0 flex-col flex-1">
    <div x-ref="scroller" x-on:scroll="onScroll()" role="log" aria-live="polite" aria-relevant="additions" aria-label="Chat with us" tabindex="0"
        class="min-h-0 flex-1 overflow-y-auto overscroll-contain outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-nq-focus">
        <div x-ref="content" class="flex flex-col gap-3 p-4"><div data-slot="chat-message" data-side="assistant"          class="flex max-w-[85%] items-start gap-2 self-start">
            <span data-slot="avatar" x-data="nqAvatar(0)"
    class="inline-flex shrink-0 select-none items-center justify-center overflow-hidden bg-secondary align-middle font-medium text-secondary-foreground size-6 text-[10px] rounded-full mt-0.5">
                    <span data-slot="avatar-fallback" class="flex size-full items-center justify-center" x-bind="fallback"
             role="img" aria-label="Layla" >L</span>
    </span>
        <div class="flex min-w-0 flex-col gap-1 items-start">
                    <div class="flex items-center gap-2 text-caption text-muted-foreground">
                <span class="text-label text-foreground">Layla</span>                            </div>
                <div data-slot="chat-bubble"             class="min-w-0 rounded-card px-3 py-2 text-body border border-border bg-card text-nq-fg-body">
                                                <p dir="auto" class="whitespace-pre-wrap text-start [overflow-wrap:anywhere]">Hi! How can we help you today?</p>
                                                </div>
            </div>
</div>
            <template x-for="m in outbox" x-bind:key="m.id">
                <div data-slot="chat-message" data-side="user" x-bind:data-status="m.status" class="flex max-w-[85%] items-start gap-2 flex-row-reverse self-end">
                    <div class="flex min-w-0 flex-col gap-1 items-end">
                        <div data-slot="chat-bubble" class="min-w-0 rounded-card px-3 py-2 text-body bg-secondary text-secondary-foreground">
                            <p x-show="m.text" x-text="m.text" dir="auto" class="whitespace-pre-wrap text-start [overflow-wrap:anywhere]"></p>
                            <ul x-show="m.files.length" class="mt-2 flex flex-col gap-1.5">
                                <template x-for="name in m.files">
                                    <li><span dir="auto" class="inline-flex items-center gap-1 text-body-sm"><svg aria-hidden="true" class="size-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"/>
</svg><span x-text="name"></span></span></li>
                                </template>
                            </ul>
                        </div>
                        <div data-slot="chat-status" class="flex items-center gap-1.5 text-caption text-muted-foreground">
                            <span x-show="m.status === 'sending'" role="status">Sending…</span>
                            <span x-show="m.status === 'sent'" class="flex items-center gap-1.5"><svg aria-hidden="true" class="size-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M20 6 9 17l-5-5"/>
</svg><span>Sent</span></span>
                        </div>
                    </div>
                </div>
            </template>
                            <div role="group" aria-label="Quick questions" x-show="! hasVisitor()"  class="flex flex-wrap gap-2">
                                            <button type="button" dir="auto" x-on:click="submit('Pricing')" class="rounded-full border border-border bg-background px-3 py-1.5 text-body-sm text-foreground outline-none transition-colors duration-150 ease-nq hover:bg-nq-hover focus-visible:outline-2 focus-visible:outline-nq-focus">Pricing</button>
                                            <button type="button" dir="auto" x-on:click="submit('Talk to sales')" class="rounded-full border border-border bg-background px-3 py-1.5 text-body-sm text-foreground outline-none transition-colors duration-150 ease-nq hover:bg-nq-hover focus-visible:outline-2 focus-visible:outline-nq-focus">Talk to sales</button>
                                    </div></div>
    </div>
    <button data-slot="chat-jump"
     type="button"                         x-show="away" x-cloak="x-cloak" x-on:click="jump()" aria-label="Jump to latest" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap border font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 border-border bg-card text-foreground hover:bg-nq-hover size-control-sm p-0 absolute end-4 bottom-3 rounded-full shadow-sm">
        <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M12 5v14"/>
  <path d="m19 12-7 7-7-7"/>
</svg></button>
</div>
        <p role="alert" x-show="error" x-text="error" style="display: none" class="px-4 pb-2 text-caption text-nq-danger-text"></p>
                    <div class="border-t border-border p-3">
                <input type="file" multiple hidden x-on:change="addFiles($event)" />
                <div data-slot="chat-composer" x-data="nqChatComposer('', JSON.parse('{\u0022streaming\u0022:false,\u0022stoppable\u0022:false,\u0022disabled\u0022:false,\u0022maxRows\u0022:4}'))" x-modelable="text"
    x-on:nq-chat-streaming="streaming = $event.detail.streaming"
        x-model="draft" x-on:nq-send.stop="submit($event.detail.text)" class="flex flex-col gap-2 rounded-card border border-input bg-card p-2 transition-colors duration-150 ease-nq focus-within:border-nq-focus data-disabled:opacity-50">
            <div data-slot="chat-attachments" class="flex flex-wrap gap-2"><template x-for="(f, i) in files" x-bind:key="i + f.name">
                            <span class="inline-flex h-7 max-w-48 items-center gap-1 rounded-full border border-border bg-secondary ps-2.5 pe-1 text-caption text-foreground">
                                <span dir="auto" class="truncate" x-text="f.name"></span>
                                <button type="button" x-bind:aria-label="removeLabel(f.name)" x-on:click="removeFile(i)"
                                    class="grid size-5 place-items-center rounded-full outline-none hover:bg-nq-hover focus-visible:outline-2 focus-visible:outline-nq-focus">
                                    <svg aria-hidden="true" class="size-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M18 6 6 18"/>
  <path d="m6 6 12 12"/>
</svg>                                </button>
                            </span>
                        </template></div>
        <div class="flex items-end gap-2">
        <textarea data-slot="textarea"
                x-ref="field" x-model="text" x-on:keydown="key($event)" rows="1" dir="auto" placeholder="Write a message" aria-label="Message" class="w-full min-w-0 rounded-control border-input text-body text-foreground transition-colors duration-150 ease-nq outline-none placeholder:text-muted-foreground focus-visible:border-nq-focus focus-visible:outline-1 focus-visible:outline-nq-focus data-invalid:border-nq-danger aria-invalid:border-nq-danger disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:text-[16px] min-h-0 flex-1 resize-none border-0 bg-transparent px-2 py-1.5 focus-visible:outline-0"></textarea>
        <button data-slot="button"
     type="button"                         x-on:click="pick()" aria-label="Attach a file" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 text-foreground hover:bg-nq-hover size-control p-0">
        <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"/>
</svg></button>                <button data-slot="button"
     type="button"                         x-show="! stopping()" x-on:click="send()" x-bind:disabled="! canSend()" aria-label="Send" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--nq-action)_88%,var(--nq-fg))] size-control p-0">
        <svg aria-hidden="true" class="rtl:-scale-x-100" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/>
  <path d="m21.854 2.147-10.94 10.939"/>
</svg></button>
    </div>
</div>
            </div>
            </section>
    <button data-slot="button"
     type="button"                         x-ref="launcher" x-on:click="toggle()" x-bind:aria-label="launcherLabel()" x-bind:aria-expanded="open ? &#039;true&#039; : &#039;false&#039;" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--nq-action)_88%,var(--nq-fg))] p-0 relative size-14 rounded-full shadow-floating">
        <svg x-show="open" style="" aria-hidden="true" class="size-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M18 6 6 18"/>
  <path d="m6 6 12 12"/>
</svg>        <svg x-show="! open" style="display: none" aria-hidden="true" class="size-6 rtl:-scale-x-100" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"/>
</svg></button>
</div>
</div>
```
