Slots

Customize any part of the chat UI by overriding individual sub-components via slots.

Open your coding agent in your project's folder, or in an empty folder for a new app.This runs in a coding agent on your computer.

What is this?#

Every CopilotKit chat component is built from composable slots — named sub-components that you can override individually. The slot system gives you three levels of customization without needing to rebuild the entire UI:

  1. Tailwind classes — pass a string to add/override CSS classes
  2. Props override — pass an object to override specific props on the default component
  3. Custom component — pass your own React component to fully replace a slot

Slots are recursive — you can drill into nested sub-components at any depth.

Tailwind Classes#

The simplest way to customize a slot. Pass a Tailwind class string and it will be merged with the default component's classes.

page.tsx
import { CopilotChat } from "@copilotkit/react-core/v2";

export function Chat() {
  return (
    <CopilotChat
      messageView="bg-gray-50 dark:bg-gray-900 p-4"
      input="border-2 border-blue-400 rounded-xl"
    />
  );
}

Props Override#

Pass an object to override specific props on the default component. This is useful for adding className, event handlers, data attributes, or any other prop the default component accepts.

page.tsx
<CopilotChat
  messageView={{
    className: "my-custom-messages",
    "data-testid": "message-view",
  }}
  input={{ autoFocus: true }}
/>

Custom Components#

For full control, pass your own React component. It receives all the same props as the default component.

page.tsx
import { CopilotChat } from "@copilotkit/react-core/v2";

const CustomMessageView = ({ messages, isRunning }) => (
  <div className="space-y-4 p-6">
    {messages?.map((msg) => (
      <div
        key={msg.id}
        className={msg.role === "user" ? "text-right" : "text-left"}
      >
        {msg.content}
      </div>
    ))}
    {isRunning && <div className="animate-pulse">Thinking...</div>}
  </div>
);

export function Chat() {
  return (
    <CopilotChat messageView={CustomMessageView} />
  );
}

Nested Slots (Drill-Down)#

Slots are recursive. You can customize sub-components at any depth by nesting objects.

Two levels deep#

Override the assistant message's toolbar within the message view:

page.tsx
<CopilotChat
  messageView={{
    assistantMessage: {
      toolbar: CustomToolbar,
      copyButton: CustomCopyButton,
    },
    userMessage: CustomUserMessage,
  }}
/>

Three levels deep#

Override a specific button inside the assistant message toolbar:

page.tsx
<CopilotChat
  messageView={{
    assistantMessage: {
      copyButton: ({ onClick }) => <button onClick={onClick}>Copy</button>,
    },
  }}
/>

Input sub-slots#

page.tsx
<CopilotChat
  input={{
    textArea: CustomTextArea,
    sendButton: CustomSendButton,
  }}
/>

Scroll view sub-slots#

page.tsx
<CopilotChat
  scrollView={{
    feather: CustomFeather,
    scrollToBottomButton: CustomScrollButton,
  }}
/>

Suggestion view sub-slots#

page.tsx
<CopilotChat
  suggestionView={{
    suggestion: CustomSuggestionPill,
    container: CustomSuggestionContainer,
  }}
/>

Children Render Function#

For complete layout control, use the children render function pattern. This gives you pre-built slot elements that you can arrange however you want.

page.tsx
import { CopilotChat } from "@copilotkit/react-core/v2";

export function Chat() {
  return (
    <CopilotChat>
      {({ messageView, input, scrollView, suggestionView }) => (
        <div className="flex flex-col h-full">
          <header className="p-4 border-b font-semibold">My Agent</header>
          {scrollView}
          <div className="border-t p-4">{input}</div>
        </div>
      )}
    </CopilotChat>
  );
}

Reshaping the Message List#

Slots change how each message renders. To change which messages render — hide some, replace them, reorder them — pass transformMessages to the message view. It receives the whole list and returns the list to render.

page.tsx
import { useCallback } from "react";
import { CopilotChat, type Message } from "@copilotkit/react-core/v2";

export function Chat() {
  const transformMessages = useCallback(
    (messages: Message[]) => messages.filter((m) => m.role !== "tool"),
    [],
  );

  return <CopilotChat messageView={{ transformMessages }} />;
}

A few things to know:

  • Long threads stay virtualized. Once a thread passes 50 messages the chat only mounts the rows on screen. It counts the messages transformMessages returns, so hidden messages take no row.
  • Tool cards keep their results. Tool-call renderers look up results in the full list, so hiding tool-result messages, as above, does not empty the cards that display them.
  • Keep ids unique and stable. Return each message id at most once. A message you build yourself, such as one that stands in for several, needs its own id, and that id must stay the same while the thread streams. Derive it from something fixed, like the id of the first message it replaces. The chat uses the last message you return to decide which one is still streaming.
  • Pass a stable function. The transform reruns whenever the messages or the function change. An inline function works, but reruns on every render.

Grouping messages into one row#

To render several messages together — a run of tool calls collapsed into one block, say — pass groupMessages. It receives the list (after transformMessages, if you use both) and returns rows: messageRow(message) for a message on its own, messageGroup({ key, messages, wrapper }) for messages rendered together inside your wrapper.

page.tsx
import { useCallback } from "react";
import {
  CopilotChat,
  messageGroup,
  messageRow,
  type Message,
  type MessageGroupWrapperProps,
  type MessageRow,
} from "@copilotkit/react-core/v2";

// Define wrappers at module level. One created inside groupMessages is a new
// component on every call, so React would remount it each time.
function ToolActivity({
  messages,
  children,
  state,
  setState,
}: MessageGroupWrapperProps<boolean>) {
  const open = state ?? false;
  return (
    <div className="rounded-lg border">
      <button onClick={() => setState(!open)}>
        {messages.length} tool steps {open ? "▾" : "▸"}
      </button>
      {open && children}
    </div>
  );
}

// An assistant message that also carries text stays on its own row, so the
// text (or a card waiting on the user) is never folded into a closed group.
const isToolStep = (m: Message) =>
  m.role === "tool" ||
  (m.role === "assistant" && !!m.toolCalls?.length && !m.content);

export function Chat() {
  const groupMessages = useCallback((messages: Message[]): MessageRow[] => {
    const rows: MessageRow[] = [];
    let run: Message[] = [];
    const flush = () => {
      if (run.length > 0) {
        rows.push(
          messageGroup({ key: run[0].id, messages: run, wrapper: ToolActivity }),
        );
      }
      run = [];
    };
    for (const message of messages) {
      if (isToolStep(message)) run.push(message);
      else {
        flush();
        rows.push(messageRow(message));
      }
    }
    flush();
    return rows;
  }, []);

  return <CopilotChat messageView={{ groupMessages }} />;
}
  • A group is one row. Virtualization windows and measures it as a whole, and expanding it is just that row growing. The 50-message threshold still counts messages, so a long thread folded into a few groups stays virtualized.
  • The wrapper's state outlives the row. state and setState are kept by the chat under the group's key, so a block someone expanded is still expanded when they scroll back to it. They are dropped once the key no longer appears, and cleared when the thread changes. Key a group by the id of its first message: if the backend renames that message mid-stream (some LangChain providers swap a temporary lc_run--… id for the final one), the chat carries the group over the same way it does a single message, so it neither remounts nor loses its state. Any other key must stay the same while the thread streams.
  • children is the default rendering of the group's messages. Wrap it, hide it, or render the messages yourself from messages.
  • Each message goes in at most one row. In development the chat warns when a message renders twice or two groups share a key.

If you pass the message view a children render function to lay out the rendered messages yourself, virtualization turns off and every message is mounted. Prefer transformMessages and groupMessages for list-level changes; in development the chat warns when children is what turned virtualization off.

Labels#

Customize any text string in the UI via the labels prop. This does not use the slot system — it's a separate convenience prop on CopilotChat, CopilotSidebar, and CopilotPopup.

page.tsx
<CopilotChat
  labels={{
    chatInputPlaceholder: "Ask your agent anything...",
    welcomeMessageText: "How can I help you today?",
    chatDisclaimerText: "AI responses may be inaccurate.",
  }}
/>

Available Slots#

CopilotChat / CopilotSidebar / CopilotPopup#

These are the root-level slot props available on all chat components:

SlotDescription
messageViewThe message list container.
scrollViewThe scroll container with auto-scroll behavior.
inputThe text input area with send/transcribe controls.
suggestionViewThe suggestion pills shown below messages.
welcomeScreenThe initial empty-state screen (pass false to disable).

CopilotSidebar and CopilotPopup also have:

SlotDescription
headerThe modal header bar.
toggleButtonThe open/close toggle button.

messageView sub-slots#

Available via messageView={{ ... }}:

SlotDescription
assistantMessageRenders assistant responses. Has its own sub-slots (see below).
userMessageRenders user messages. Has its own sub-slots (see below).
reasoningMessageRenders model reasoning/thinking steps. Has its own sub-slots (see below).
cursorThe streaming cursor indicator shown while the agent is responding.

assistantMessage sub-slots#

Available via messageView={{ assistantMessage: { ... } }}:

SlotDescription
markdownRendererThe markdown rendering component. See Markdown Rendering.
toolbarThe action toolbar below messages.
copyButtonCopy message button.
thumbsUpButtonThumbs up feedback button.
thumbsDownButtonThumbs down feedback button.
readAloudButtonRead aloud button.
regenerateButtonRegenerate response button.
toolCallsViewTool call visualization.

userMessage sub-slots#

Available via messageView={{ userMessage: { ... } }}:

SlotDescription
messageRendererThe text rendering component for user messages.
toolbarThe action toolbar on hover.
copyButtonCopy message button.
editButtonEdit message button.
branchNavigationNavigation between message branches (after editing).

reasoningMessage sub-slots#

Available via messageView={{ reasoningMessage: { ... } }}:

SlotDescription
headerThe collapsible header (click to expand/collapse).
contentViewThe reasoning content area.
toggleThe expand/collapse toggle wrapper.

input sub-slots#

Available via input={{ ... }}:

SlotDescription
textAreaThe text input element.
sendButtonThe send/submit button.
addMenuButtonThe attachment/tools menu button.
startTranscribeButtonButton to start voice transcription.
cancelTranscribeButtonButton to cancel transcription.
finishTranscribeButtonButton to finish transcription.
audioRecorderThe audio recorder component.
disclaimerThe disclaimer text below the input.

scrollView sub-slots#

Available via scrollView={{ ... }}:

SlotDescription
featherThe gradient overlay at the bottom of the scroll area.
scrollToBottomButtonThe button that appears when scrolled up.

suggestionView sub-slots#

Available via suggestionView={{ ... }}:

SlotDescription
suggestionIndividual suggestion pill/button.
containerThe container wrapping all suggestion pills.

welcomeScreen sub-slots#

Available via welcomeScreen={{ ... }}:

SlotDescription
welcomeMessageThe welcome text shown on the empty state.

header sub-slots (Sidebar/Popup only)#

Available via header={{ ... }}:

SlotDescription
titleContentThe title text in the header.
closeButtonThe close/minimize button.

toggleButton sub-slots (Sidebar/Popup only)#

Available via toggleButton={{ ... }}:

SlotDescription
openIconIcon shown when the chat is closed.
closeIconIcon shown when the chat is open.