Agents
ChatComposer
Prompt composer with controlled text, submit state, and trigger-menu support.
Composition
Anatomy
- <ChatComposer>
- <ChatComposerShell>
- <ChatComposerAccent />
- <ChatComposerTextarea />
- <ChatComposerToolbar>
- <ChatComposerTools />
- <ChatComposerSubmit />
- <ChatComposerFooter />
- <ChatComposerShell>
Installation
First install and activate one skin. Core deliberately contains no visual token defaults.
This agent installs from the chat-composer registry. Install the bundle with the command above, or inspect the source below.
npx shadcn@latest add https://control-ui.dev/r/chat-composer.jsonUsage
"use client";import { useChat } from "@mastra/react";import { ChatComposer, ChatComposerShell, ChatComposerSubmit, ChatComposerTextarea, ChatComposerToolbar, ChatComposerTools,} from "@/components/control-ui/chat-composer";export function Example({ agentId }: { agentId: string }) { const { isRunning, sendMessage } = useChat({ agentId }); return ( <ChatComposer state={isRunning ? "submitting" : "idle"} onSubmit={async ({ value, clear }) => { await sendMessage({ message: value }); clear(); }} > <ChatComposerShell> <ChatComposerTextarea placeholder="Ask anything..." /> <ChatComposerToolbar> <ChatComposerTools>Mastra agent</ChatComposerTools> <ChatComposerSubmit>Send</ChatComposerSubmit> </ChatComposerToolbar> </ChatComposerShell> </ChatComposer> );}Dependencies
Behavior hook
src/registry/hooks/use-chat-composer.tsHookUse keyboard navigation
src/registry/hooks/use-keyboard-navigation.tsHookEditor motion (blur choreography)
src/registry/sources/control-ui/chat-composer-editor.cssSupportRich editor (ProseMirror)
src/registry/sources/control-ui/chat-composer-editor.tsxSupportMention extension
src/registry/sources/control-ui/chat-composer-editor/extensions/mention.tsxSupportExit-animation ghost
src/registry/sources/control-ui/chat-composer-editor/ghost.tsSupportEditor schema
src/registry/sources/control-ui/chat-composer-editor/schema.tsSupportSerialize
src/registry/sources/control-ui/chat-composer-editor/serialize.tsSupportExtension contract
Buttonsrc/registry/sources/control-ui/chat-composer-editor/types.tsSupportbuttonPrimitiveChatMessagechat-messageAgentTrigger Menutrigger-menuPrimitiveRaw code
This agent’s owned source and private support files
"use client";import type { ChangeEvent, ComponentProps, CSSProperties, KeyboardEvent } from "react";import { createContext, useContext } from "react";import type { ChatComposerProps } from "@/components/control-ui/hooks/use-chat-composer";import { useChatComposer } from "@/components/control-ui/hooks/use-chat-composer";import { useKeyboardNavigation } from "@/components/control-ui/hooks/use-keyboard-navigation";import type { ChatComposerKnobStyle } from "@/components/control-ui/knob-contracts/chat-composer-knobs";import { cn } from "@/components/control-ui/lib/cn";import { hasSkinAdornment, skinAdornment } from "@/components/control-ui/skin";import { useSkin } from "@/components/control-ui/skin-provider";import { Button } from "@/components/control-ui/ui/button";type ChatComposerContextValue = ReturnType<typeof useChatComposer>;const ChatComposerContext = createContext<ChatComposerContextValue | null>(null);export function useChatComposerContext() { const context = useContext(ChatComposerContext); if (!context) throw new Error("ChatComposer compound components must be rendered inside <ChatComposer>."); return context;}export function ChatComposer({ value, defaultValue, onValueChange, onSubmit, state = "idle", density = "comfortable", disabled = false, className, children, ...props}: ChatComposerProps) { const skin = useSkin(); const input = useChatComposer({ value, defaultValue, onValueChange, onSubmit, state, density, disabled, trackSends: hasSkinAdornment(skin, "chat-composer", "send-layer"), }); const sendLayer = skinAdornment(skin, "chat-composer", "send-layer", { sendCount: input.sendCount }); return ( <ChatComposerContext.Provider value={input}> <form data-control-ui="chat-composer" data-control-family="chat-composer" data-slot="root" data-state={state} data-density={density} onSubmit={input.handleSubmit} className={cn("relative min-w-0 w-full", className)} {...props} > {sendLayer !== undefined && sendLayer !== null ? ( <div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 overflow-clip contain-paint"> {sendLayer} </div> ) : null} {children} </form> </ChatComposerContext.Provider> );}export type ChatComposerShellProps = Omit<ComponentProps<"div">, "style"> & { style?: CSSProperties & ChatComposerKnobStyle };export function ChatComposerShell({ className, ...props }: ChatComposerShellProps) { const input = useChatComposerContext(); const keyboardNavigation = useKeyboardNavigation(); return ( <div data-keyboard-navigation={keyboardNavigation ? "" : undefined} data-control-ui="chat-composer" data-control-family="chat-composer" data-slot="shell" data-state={input.state} data-surface="panel" className={cn("relative overflow-hidden", className)} {...props} /> );}export type ChatComposerAccentProps = Omit<ComponentProps<"div">, "style"> & { style?: CSSProperties & ChatComposerKnobStyle };export function ChatComposerAccent({ className, ...props }: ChatComposerAccentProps) { return ( <div data-control-ui="chat-composer" data-control-family="chat-composer" data-slot="accent" aria-hidden="true" className={cn("pointer-events-none absolute inset-x-5 top-0", className)} {...props} /> );}export type ChatComposerTextareaProps = Omit<ComponentProps<"textarea">, "style"> & { style?: CSSProperties & ChatComposerKnobStyle;};export function ChatComposerTextarea({ className, rows, disabled, onChange, onKeyDown, ...props }: ChatComposerTextareaProps) { const input = useChatComposerContext(); function handleChange(event: ChangeEvent<HTMLTextAreaElement>) { onChange?.(event); if (!event.defaultPrevented) input.setValue(event.currentTarget.value); } function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) { onKeyDown?.(event); input.handleKeyDown(event); } return ( <textarea data-control-ui="chat-composer" data-control-family="chat-composer" data-slot="textarea" aria-label="Message" {...props} value={input.value} onChange={handleChange} onKeyDown={handleKeyDown} disabled={disabled || input.isDisabled} rows={rows ?? input.rows} className={cn( "field-sizing-content min-h-16 max-h-[40dvh] w-full resize-none outline-none px-[var(--padding-x)] py-[var(--padding-y)] disabled:cursor-not-allowed", className, )} /> );}export type ChatComposerToolbarProps = ComponentProps<"div"> & { style?: CSSProperties & ChatComposerKnobStyle };export function ChatComposerToolbar({ className, ...props }: ChatComposerToolbarProps) { return ( <div data-control-ui="chat-composer" data-control-family="chat-composer" data-slot="toolbar" className={cn("flex items-center justify-between", className)} {...props} /> );}export type ChatComposerToolsProps = Omit<ComponentProps<"div">, "style"> & { style?: CSSProperties & ChatComposerKnobStyle };export function ChatComposerTools({ className, ...props }: ChatComposerToolsProps) { return ( <div data-control-ui="chat-composer" data-control-family="chat-composer" data-slot="tools" className={cn("flex min-w-0 flex-wrap items-center", className)} {...props} /> );}export type ChatComposerFooterProps = Omit<ComponentProps<"div">, "style"> & { style?: CSSProperties & ChatComposerKnobStyle };export function ChatComposerFooter({ className, ...props }: ChatComposerFooterProps) { return ( <div data-control-ui="chat-composer" data-control-family="chat-composer" data-slot="footer" className={cn(className)} {...props} /> );}export type ChatComposerSubmitProps = ComponentProps<typeof Button>;export function ChatComposerSubmit({ className, disabled, children = "Send", ...props }: ChatComposerSubmitProps) { const input = useChatComposerContext(); return ( <Button data-control-ui="chat-composer" data-slot="submit" type="submit" variant="solid" tone="primary" size="xs" disabled={disabled || !input.canSubmit} className={className} {...props} > {children} </Button> );}Available extensions
Optional, separately installed items this surface can host — not part of the component’s bundle.
SendAuroraAnchored
Anchored ChatComposer extension: a blurred aurora backdrop that sweeps up once per sent message — activated from skin.config via the chat-composer:send-layer anchor.
npx shadcn@latest add https://control-ui.dev/r/send-aurora.json// skin.config.tsx (import the "use client" extension; the config itself stays RSC-pure)import { SendAurora } from "@/components/control-ui/extensions/send-aurora";export const skin: ControlUiSkin = { id: "my-brand", adornments: { "chat-composer": { "send-layer": (ctx) => <SendAurora sendCount={ctx.sendCount} />, }, },};Knobs
Typed custom properties the recipe paints with. Set one on the root — style, a utility class, or a skin — and every slot inherits it.
--cui-chat-composer-* · 14 knobsHow the cascade resolves--cui-chat-composer-root-background<color>transparent--cui-chat-composer-shell-radius<length>var(--radius-field)--cui-chat-composer-shell-background<color>var(--card)--cui-chat-composer-shell-background-image*none--cui-chat-composer-shell-backdrop-filter*none--cui-chat-composer-shell-border-color<color>var(--border)--cui-chat-composer-shell-border-width<length>1px--cui-chat-composer-shell-shadow*var(--shadow-md)--cui-chat-composer-input-foreground<color>var(--card-foreground)--cui-chat-composer-input-placeholder-foreground<color>var(--muted-foreground)--cui-chat-composer-mention-background<color>oklch(from var(--primary) l c h / 0.1)--cui-chat-composer-mention-radius<length>var(--radius-popup-item)--cui-chat-composer-mention-border-color<color>transparent--cui-chat-composer-mention-border-width<length>0px