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/composerThe 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.