Skip to content
Notra logoNotra UI
Esc
↑↓navigate↵open⌘Jpreview
On this page

Composer

The chat composer from the Notra dashboard, a muted tray around the input card.

"use client";

import {
  ArrowUpIcon,
  AtSignIcon,
  BrainIcon,
  ChevronDownIcon,
  PlusIcon,
} from "lucide-react";
import { useState } from "react";

import {
  ComposerFrame,
  ComposerInput,
  ComposerSend,
  ComposerToolbar,
  ComposerToolbarButton,
} from "../components/composer";

export default function ComposerExample() {
  const [value, setValue] = useState("");

  return (
    <div className="w-full max-w-xl p-6">
      <ComposerFrame>
        <ComposerInput
          aria-label="Message"
          onChange={(event) => setValue(event.target.value)}
          placeholder="Send a message..."
          rows={1}
          value={value}
        />
        <ComposerToolbar>
          <ComposerToolbarButton>
            Auto
            <ChevronDownIcon className="size-3" />
          </ComposerToolbarButton>
          <ComposerToolbarButton>
            <BrainIcon />
            Medium
            <ChevronDownIcon className="size-3" />
          </ComposerToolbarButton>
          <div className="ml-auto flex items-center gap-1">
            <ComposerToolbarButton aria-label="Add context" size="icon-sm">
              <AtSignIcon />
            </ComposerToolbarButton>
            <ComposerToolbarButton aria-label="Attach files" size="icon-sm">
              <PlusIcon />
            </ComposerToolbarButton>
            <ComposerSend
              disabled={value.trim().length === 0}
              label="Send message"
              onClick={() => setValue("")}
              tooltip="Send"
            >
              <ArrowUpIcon className="size-4" strokeWidth={2.25} />
            </ComposerSend>
          </div>
        </ComposerToolbar>
      </ComposerFrame>
    </div>
  );
}
$ bunx --bun shadcn@latest add @notra/composer

The frame is a muted tray with the input card inside. The toolbar buttons and the send button are the Notra Button, the input is the shadcn Textarea with its field chrome removed, because the card is the field. The send tooltip is the Notra Tooltip. A disabled send button stays focusable, so the tooltip can still say why it is disabled.

<ComposerFrame>
  <ComposerInput aria-label="Message" placeholder="Send a message..." rows={1} />
  <ComposerToolbar>
    <ComposerToolbarButton>
      Auto
    </ComposerToolbarButton>
    <div className="ml-auto flex items-center gap-1">
      <ComposerToolbarButton aria-label="Attach files" size="icon-sm">
        <PlusIcon />
      </ComposerToolbarButton>
      <ComposerSend disabled={!value} label="Send message" tooltip="Send">
        <ArrowUpIcon className="size-4" />
      </ComposerSend>
    </div>
  </ComposerToolbar>
</ComposerFrame>

Queue and attachments

Pass a ComposerNudge to nudge and it slides in on the tray above the card. Queued messages and files are ComposerChips. Each chip shows the actions you give it: send now, edit and remove.

"use client";

import { ArrowUpIcon, FileTextIcon, SquareIcon } from "lucide-react";
import { useState } from "react";

import {
  ComposerChip,
  ComposerFrame,
  ComposerInput,
  ComposerNudge,
  ComposerSend,
  ComposerToolbar,
} from "../components/composer";

const INITIAL_QUEUE = [
  { id: "pricing-table", text: "Add a comparison table for the pricing page" },
  { id: "short-intro", text: "Keep the intro under 80 words" },
];

export default function ComposerQueueExample() {
  const [queue, setQueue] = useState(INITIAL_QUEUE);
  const [hasFile, setHasFile] = useState(true);
  const [value, setValue] = useState("");
  const hasNudge = queue.length > 0 || hasFile;

  return (
    <div className="w-full max-w-xl p-6">
      <ComposerFrame
        nudge={
          hasNudge ? (
            <ComposerNudge>
              {queue.map((message) => (
                <ComposerChip
                  key={message.id}
                  label={message.text}
                  onEdit={() => {
                    setValue(message.text);
                    setQueue((items) =>
                      items.filter((item) => item.id !== message.id)
                    );
                  }}
                  onRemove={() =>
                    setQueue((items) =>
                      items.filter((item) => item.id !== message.id)
                    )
                  }
                  onSteer={() =>
                    setQueue((items) =>
                      items.filter((item) => item.id !== message.id)
                    )
                  }
                />
              ))}
              {hasFile ? (
                <ComposerChip
                  icon={
                    <FileTextIcon className="text-muted-foreground size-3.5" />
                  }
                  label="linear-sync-changelog.pdf"
                  onRemove={() => setHasFile(false)}
                />
              ) : null}
            </ComposerNudge>
          ) : null
        }
      >
        <ComposerInput
          aria-label="Message"
          onChange={(event) => setValue(event.target.value)}
          placeholder="Queue another message..."
          rows={1}
          value={value}
        />
        <ComposerToolbar>
          <div className="ml-auto flex items-center gap-1">
            <ComposerSend
              label={value.trim() ? "Queue message" : "Stop"}
              onClick={() => {
                if (!value.trim()) {
                  return;
                }
                const message = { id: crypto.randomUUID(), text: value.trim() };
                setQueue((items) => [...items, message]);
                setValue("");
              }}
              tooltip={value.trim() ? "Queue" : "Stop"}
            >
              {value.trim() ? (
                <ArrowUpIcon className="size-4" strokeWidth={2.25} />
              ) : (
                <SquareIcon className="size-3 fill-current" />
              )}
            </ComposerSend>
          </div>
        </ComposerToolbar>
      </ComposerFrame>
    </div>
  );
}

States

Every state the dashboard composer goes through. The send button carries most of them: send, stop, a spinner while stopping or while a queued message waits, and muted while there is nothing to send.

Empty

Nothing to send yet, so the send button is muted.

"use client";

import { ArrowUpIcon, ChevronDownIcon, PlusIcon } from "lucide-react";

import {
  ComposerFrame,
  ComposerInput,
  ComposerSend,
  ComposerToolbar,
  ComposerToolbarButton,
} from "../components/composer";

function noop() {}

export default function ComposerStateEmpty() {
  return (
    <div className="w-full max-w-xl p-6">
      <ComposerFrame>
        <ComposerInput
          aria-label="Message"
          placeholder="Send a message..."
          rows={1}
        />
        <ComposerToolbar>
          <ComposerToolbarButton>
            Auto
            <ChevronDownIcon className="size-3" />
          </ComposerToolbarButton>
          <div className="ml-auto flex items-center gap-1">
            <ComposerToolbarButton aria-label="Attach files" size="icon-sm">
              <PlusIcon />
            </ComposerToolbarButton>
            <ComposerSend disabled label="Send" onClick={noop}>
              <ArrowUpIcon className="size-4" strokeWidth={2.25} />
            </ComposerSend>
          </div>
        </ComposerToolbar>
      </ComposerFrame>
    </div>
  );
}

Ready

Text in the input turns send on.

"use client";

import { ArrowUpIcon, ChevronDownIcon, PlusIcon } from "lucide-react";

import {
  ComposerFrame,
  ComposerInput,
  ComposerSend,
  ComposerToolbar,
  ComposerToolbarButton,
} from "../components/composer";

function noop() {}

export default function ComposerStateReady() {
  return (
    <div className="w-full max-w-xl p-6">
      <ComposerFrame>
        <ComposerInput
          aria-label="Message"
          defaultValue="Draft release notes for the October launch"
          placeholder="Send a message..."
          rows={1}
        />
        <ComposerToolbar>
          <ComposerToolbarButton>
            Auto
            <ChevronDownIcon className="size-3" />
          </ComposerToolbarButton>
          <div className="ml-auto flex items-center gap-1">
            <ComposerToolbarButton aria-label="Attach files" size="icon-sm">
              <PlusIcon />
            </ComposerToolbarButton>
            <ComposerSend label="Send message" onClick={noop}>
              <ArrowUpIcon className="size-4" strokeWidth={2.25} />
            </ComposerSend>
          </div>
        </ComposerToolbar>
      </ComposerFrame>
    </div>
  );
}

Streaming

While a reply streams, an empty composer turns send into stop.

"use client";

import { ChevronDownIcon, PlusIcon, SquareIcon } from "lucide-react";

import {
  ComposerFrame,
  ComposerInput,
  ComposerSend,
  ComposerToolbar,
  ComposerToolbarButton,
} from "../components/composer";

function noop() {}

export default function ComposerStateStreaming() {
  return (
    <div className="w-full max-w-xl p-6">
      <ComposerFrame>
        <ComposerInput
          aria-label="Message"
          placeholder="Queue a message..."
          rows={1}
        />
        <ComposerToolbar>
          <ComposerToolbarButton>
            Auto
            <ChevronDownIcon className="size-3" />
          </ComposerToolbarButton>
          <div className="ml-auto flex items-center gap-1">
            <ComposerToolbarButton aria-label="Attach files" size="icon-sm">
              <PlusIcon />
            </ComposerToolbarButton>
            <ComposerSend label="Stop" onClick={noop}>
              <SquareIcon className="size-3 fill-current" />
            </ComposerSend>
          </div>
        </ComposerToolbar>
      </ComposerFrame>
    </div>
  );
}

Stopping

Stop was pressed and the reply is winding down. busy swaps the icon for a spinner.

"use client";

import { ChevronDownIcon, PlusIcon, SquareIcon } from "lucide-react";

import {
  ComposerFrame,
  ComposerInput,
  ComposerSend,
  ComposerToolbar,
  ComposerToolbarButton,
} from "../components/composer";

function noop() {}

export default function ComposerStateStopping() {
  return (
    <div className="w-full max-w-xl p-6">
      <ComposerFrame>
        <ComposerInput
          aria-label="Message"
          placeholder="Queue a message..."
          rows={1}
        />
        <ComposerToolbar>
          <ComposerToolbarButton>
            Auto
            <ChevronDownIcon className="size-3" />
          </ComposerToolbarButton>
          <div className="ml-auto flex items-center gap-1">
            <ComposerToolbarButton aria-label="Attach files" size="icon-sm">
              <PlusIcon />
            </ComposerToolbarButton>
            <ComposerSend busy label="Stopping" onClick={noop}>
              <SquareIcon className="size-3 fill-current" />
            </ComposerSend>
          </div>
        </ComposerToolbar>
      </ComposerFrame>
    </div>
  );
}

Queued

Typing while a reply streams queues the message as a chip on the tray. It can be sent right away, edited or removed.

"use client";

import { ArrowUpIcon, ChevronDownIcon, PlusIcon } from "lucide-react";

import {
  ComposerChip,
  ComposerFrame,
  ComposerInput,
  ComposerNudge,
  ComposerSend,
  ComposerToolbar,
  ComposerToolbarButton,
} from "../components/composer";

function noop() {}

export default function ComposerStateQueued() {
  return (
    <div className="w-full max-w-xl p-6">
      <ComposerFrame
        nudge={
          <ComposerNudge>
            <ComposerChip
              label="Keep the intro under 80 words"
              onEdit={noop}
              onRemove={noop}
              onSteer={noop}
            />
          </ComposerNudge>
        }
      >
        <ComposerInput
          aria-label="Message"
          defaultValue="Add a comparison table too"
          placeholder="Queue a message..."
          rows={1}
        />
        <ComposerToolbar>
          <ComposerToolbarButton>
            Auto
            <ChevronDownIcon className="size-3" />
          </ComposerToolbarButton>
          <div className="ml-auto flex items-center gap-1">
            <ComposerToolbarButton aria-label="Attach files" size="icon-sm">
              <PlusIcon />
            </ComposerToolbarButton>
            <ComposerSend label="Queue message" onClick={noop}>
              <ArrowUpIcon className="size-4" strokeWidth={2.25} />
            </ComposerSend>
          </div>
        </ComposerToolbar>
      </ComposerFrame>
    </div>
  );
}

Waiting to send

The message waits for the reply to finish. The input is locked and send cancels the queued message.

"use client";

import { ChevronDownIcon, PlusIcon } from "lucide-react";

import { Spinner } from "@/components/ui/spinner";

import {
  ComposerFrame,
  ComposerInput,
  ComposerSend,
  ComposerToolbar,
  ComposerToolbarButton,
} from "../components/composer";

function noop() {}

export default function ComposerStateWaiting() {
  return (
    <div className="w-full max-w-xl p-6">
      <ComposerFrame>
        <ComposerInput
          aria-label="Message"
          defaultValue="Now shorten it to a tweet"
          disabled
          placeholder="Send a message..."
          rows={1}
        />
        <ComposerToolbar>
          <ComposerToolbarButton disabled>
            Auto
            <ChevronDownIcon className="size-3" />
          </ComposerToolbarButton>
          <div className="ml-auto flex items-center gap-1">
            <ComposerToolbarButton
              aria-label="Attach files"
              disabled
              size="icon-sm"
            >
              <PlusIcon />
            </ComposerToolbarButton>
            <ComposerSend label="Cancel queued message" onClick={noop}>
              <Spinner className="size-3.5" />
            </ComposerSend>
          </div>
        </ComposerToolbar>
      </ComposerFrame>
    </div>
  );
}

Uploading

Files upload as chips on the tray. A pending chip hides its actions, and send waits until every upload is done.

"use client";

import {
  ArrowUpIcon,
  ChevronDownIcon,
  FileTextIcon,
  PlusIcon,
} from "lucide-react";

import { Spinner } from "@/components/ui/spinner";

import {
  ComposerChip,
  ComposerFrame,
  ComposerInput,
  ComposerNudge,
  ComposerSend,
  ComposerToolbar,
  ComposerToolbarButton,
} from "../components/composer";

function noop() {}

export default function ComposerStateUploading() {
  return (
    <div className="w-full max-w-xl p-6">
      <ComposerFrame
        nudge={
          <ComposerNudge>
            <ComposerChip
              icon={<FileTextIcon className="text-muted-foreground size-3.5" />}
              label="pricing-notes.pdf"
              onClick={noop}
              onRemove={noop}
            />
            <ComposerChip
              icon={<Spinner className="size-3" />}
              label="launch-screenshots.zip"
              pending
            />
          </ComposerNudge>
        }
      >
        <ComposerInput
          aria-label="Message"
          defaultValue="Summarize these files"
          placeholder="Send a message..."
          rows={1}
        />
        <ComposerToolbar>
          <ComposerToolbarButton>
            Auto
            <ChevronDownIcon className="size-3" />
          </ComposerToolbarButton>
          <div className="ml-auto flex items-center gap-1">
            <ComposerToolbarButton aria-label="Attach files" size="icon-sm">
              <PlusIcon />
            </ComposerToolbarButton>
            <ComposerSend disabled label="Send" onClick={noop}>
              <ArrowUpIcon className="size-4" strokeWidth={2.25} />
            </ComposerSend>
          </div>
        </ComposerToolbar>
      </ComposerFrame>
    </div>
  );
}

Notice

A title and an action on the nudge show a notice above the card.

"use client";

import {
  ArrowUpIcon,
  ChevronDownIcon,
  CircleAlertIcon,
  PlusIcon,
} from "lucide-react";

import { Button } from "@/components/ui/button";

import {
  ComposerFrame,
  ComposerInput,
  ComposerNudge,
  ComposerSend,
  ComposerToolbar,
  ComposerToolbarButton,
} from "../components/composer";

function noop() {}

export default function ComposerStateNotice() {
  return (
    <div className="w-full max-w-xl p-6">
      <ComposerFrame
        nudge={
          <ComposerNudge
            action={
              <Button size="xs" variant="outline">
                Upgrade
              </Button>
            }
            title={
              <span className="flex items-center gap-2">
                <span className="flex size-6 shrink-0 items-center justify-center rounded-lg bg-(--composer-info)/12 text-(--composer-info) [--composer-info:var(--info,oklch(0.55_0.13_250))] [corner-shape:squircle] dark:[--composer-info:var(--info,oklch(0.7_0.12_250))]">
                  <CircleAlertIcon className="size-3.5" />
                </span>
                2 chat messages left this month
              </span>
            }
          />
        }
      >
        <ComposerInput
          aria-label="Message"
          defaultValue="Write a changelog entry"
          placeholder="Send a message..."
          rows={1}
        />
        <ComposerToolbar>
          <ComposerToolbarButton>
            Auto
            <ChevronDownIcon className="size-3" />
          </ComposerToolbarButton>
          <div className="ml-auto flex items-center gap-1">
            <ComposerToolbarButton aria-label="Attach files" size="icon-sm">
              <PlusIcon />
            </ComposerToolbarButton>
            <ComposerSend label="Send message" onClick={noop}>
              <ArrowUpIcon className="size-4" strokeWidth={2.25} />
            </ComposerSend>
          </div>
        </ComposerToolbar>
      </ComposerFrame>
    </div>
  );
}

Limit reached

Sending is blocked until the limit resets or the plan changes. The nudge can hold any content, like this warning.

"use client";

import {
  ArrowUpIcon,
  ChevronDownIcon,
  PlusIcon,
  TriangleAlertIcon,
} from "lucide-react";

import { Button } from "@/components/ui/button";

import {
  ComposerFrame,
  ComposerInput,
  ComposerNudge,
  ComposerSend,
  ComposerToolbar,
  ComposerToolbarButton,
} from "../components/composer";

function noop() {}

export default function ComposerStateLimit() {
  return (
    <div className="w-full max-w-xl p-6">
      <ComposerFrame
        nudge={
          <ComposerNudge
            action={
              <Button size="xs" variant="outline">
                Upgrade
              </Button>
            }
          >
            <span className="flex min-w-0 items-center gap-2 text-sm">
              <span className="flex size-6 shrink-0 items-center justify-center rounded-lg bg-(--composer-warning)/12 text-(--composer-warning) [--composer-warning:var(--warning,oklch(0.57_0.113_55))] [corner-shape:squircle] dark:[--composer-warning:var(--warning,oklch(0.7_0.125_55))]">
                <TriangleAlertIcon className="size-3.5" />
              </span>
              <span className="truncate">You used all chat messages</span>
            </span>
          </ComposerNudge>
        }
      >
        <ComposerInput
          aria-label="Message"
          placeholder="Send a message..."
          rows={1}
        />
        <ComposerToolbar>
          <ComposerToolbarButton>
            Auto
            <ChevronDownIcon className="size-3" />
          </ComposerToolbarButton>
          <div className="ml-auto flex items-center gap-1">
            <ComposerToolbarButton aria-label="Attach files" size="icon-sm">
              <PlusIcon />
            </ComposerToolbarButton>
            <ComposerSend disabled label="Send" onClick={noop}>
              <ArrowUpIcon className="size-4" strokeWidth={2.25} />
            </ComposerSend>
          </div>
        </ComposerToolbar>
      </ComposerFrame>
    </div>
  );
}

Flat reply

Flat composers, like comments, only show the tray while there is a nudge.

"use client";

import { ArrowUpIcon, XIcon } from "lucide-react";

import { Button } from "@/components/ui/button";

import {
  ComposerFrame,
  ComposerInput,
  ComposerNudge,
  ComposerSend,
} from "../components/composer";

function noop() {}

export default function ComposerStateFlat() {
  return (
    <div className="w-full max-w-xl p-6">
      <ComposerFrame
        flat
        nudge={
          <ComposerNudge
            action={
              <Button aria-label="Cancel reply" size="icon-xs" variant="ghost">
                <XIcon />
              </Button>
            }
            title="Replying to Dominik"
          />
        }
      >
        <div className="flex items-end gap-2 p-2">
          <ComposerInput
            aria-label="Reply to Dominik"
            className="max-h-32 min-h-7 flex-1 px-2 py-1 leading-5"
            placeholder="Write a reply..."
            rows={1}
          />
          <ComposerSend label="Send" onClick={noop}>
            <ArrowUpIcon className="size-4" strokeWidth={2.25} />
          </ComposerSend>
        </div>
      </ComposerFrame>
    </div>
  );
}

Rich editors with inline mentions can skip ComposerInput and put their own contentEditable host in the frame.

Component Prop Default Description
ComposerFrame nudge Shown on the tray above the card, animated in and out.
ComposerFrame flat false Tighter radius, no shadow, and no tray until there is a nudge. For inline composers like comments.
ComposerFrame connectedTop false Drops the top radius so the frame sits flush under another surface.
ComposerChip onSteer, onEdit, onRemove Each one adds its action button. Steer and edit hide while pending.
ComposerChip onClick Makes the label a button, e.g. to preview a file.
ComposerToolbarButton size "sm" Any Button size. Use "icon-sm" for icon-only actions.
ComposerSend busy false Swaps the icon for a spinner.
ComposerSend disabled false Mutes the button. It stays focusable for the tooltip.

The chip action labels default to English, like Remove ${label}. Pass removeLabel, editLabel, steerLabel and previewLabel to translate them.