Meet Frontfriend.Your brand. Your rules. Your agents, connected.Explore what’s new

Chat

A conversation layout with user and assistant turns, anchored for navigation and paired with a prompt composer.

Interactive preview

The shared preview uses React Base. The source below uses Base UI. Each example runs with local data.

Installation

Complete project setup with react/base, then install these components:

pnpm exec frontfriend download message-scroller message bubble input-group button icon empty dropdown-menu tooltip -f react --primitive base

The viewport keeps the thread in one scroll area. Enter sends; Shift+Enter adds a line. Input composition does not send a message. Reset starts a new thread. Text is escaped by the framework. Connect your own response data when you need a provider.

Usage

Complete example

import {
  TooltipProvider,
  Tooltip,
  TooltipTrigger,
  TooltipContent,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icon";
import {
  MessageScrollerProvider,
  MessageScroller,
  MessageScrollerViewport,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerButton,
} from "@/components/ui/message-scroller";
import {
  Message,
  MessageContent,
  MessageHeader,
} from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
  Empty,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
  EmptyDescription,
} from "@/components/ui/empty";
import {
  InputGroup,
  InputGroupTextarea,
  InputGroupAddon,
  InputGroupButton,
} from "@/components/ui/input-group";
import {
  DropdownMenu,
  DropdownMenuTrigger,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import * as React from "react";
function AiMessageAction({
  label,
  icon,
  pressed,
  onActivate,
}: {
  label: string;
  icon: string;
  pressed?: boolean;
  onActivate: () => void;
}) {
  return (
    <TooltipProvider>
      <Tooltip>
        <TooltipTrigger asChild>
          <Button
            variant="ghost"
            size="icon-sm"
            aria-label={label}
            aria-pressed={pressed}
            onClick={onActivate}
          >
            <Icon name={icon} />
          </Button>
        </TooltipTrigger>
        <TooltipContent>{label}</TooltipContent>
      </Tooltip>
    </TooltipProvider>
  );
}
function AiContextMenu({ onChoose }: { onChoose: (value: string) => void }) {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <InputGroupButton
          type="button"
          size="icon-sm"
          variant="outline"
          aria-label="Add context"
        >
          <Icon name="Plus" />
        </InputGroupButton>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="start" side="top">
        <DropdownMenuItem onClick={() => onChoose("Photos and files")}>
          <Icon name="Paperclip" />
          Add Photos &amp; Files
        </DropdownMenuItem>
        <DropdownMenuSeparator />
        <DropdownMenuItem onClick={() => onChoose("Deep research")}>
          <Icon name="Search" />
          Deep Research
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => onChoose("Web search")}>
          <Icon name="Globe" />
          Web Search
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}
export default function AiChatExample() {
  const [text, setText] = React.useState(
    "Make it follow the system setting by default.",
  );
  const [context, setContext] = React.useState("");
  const [messages, setMessages] = React.useState([
    {
      id: "user-0",
      role: "user",
      text: "Add a dark mode toggle to the settings page.",
    },
    { id: "assistant-0", role: "assistant", text: "The theme provider is already in place. I added the toggle next to Appearance." },
  ]);
  function send(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (!text.trim()) return;
    setMessages((previous) => [
      ...previous,
      { id: crypto.randomUUID(), role: "user", text: text.trim() },
      {
        id: crypto.randomUUID(),
        role: "assistant",
        text: "The theme provider is already in place. I added the toggle next to Appearance.",
      },
    ]);
    setText("");
  }
  return (
    <div
      style={{
        width: "100%",
        maxWidth: 640,
        minWidth: 0,
        marginInline: "auto",
        display: "flex",
        flexDirection: "column",
        gap: 20,
        paddingBlock: 16,
        fontSize: "var(--text-sm)",
        lineHeight: 1.5,
      }}
    >
      <header
        style={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "start",
          gap: 16,
        }}
      >
        <div style={{ display: "grid", gap: 4 }}>
          <strong>New Thread</strong>
          <p>Ask the agent to change your code.</p>
        </div>
        <AiMessageAction
          label="Reset conversation"
          icon="RotateCw"
          onActivate={() => {
            setMessages([]);
            setText("");
            setContext("");
          }}
        />
      </header>
      <MessageScrollerProvider autoScroll>
        {messages.length ? (
          <MessageScroller>
            <MessageScrollerViewport
              style={{ height: "auto", maxHeight: 400 }}
              tabIndex={0}
              aria-label="Conversation"
            >
              <MessageScrollerContent style={{ gap: 24 }}>
                {messages.map((message) => (
                  <MessageScrollerItem
                    key={message.id}
                    messageId={message.id}
                    scrollAnchor={message.role === "user"}
                  >
                    <Message align={message.role === "user" ? "end" : "start"}>
                      <MessageContent>
                        {message.role === "user" ? (
                          <Bubble align="end" variant="muted">
                            <BubbleContent>{message.text}</BubbleContent>
                          </Bubble>
                        ) : (
                          <>
                            <MessageHeader>Assistant</MessageHeader>
                            <p>{message.text}</p>
                            <strong>Changes</strong>
                            <ul
                              style={{
                                paddingInlineStart: 20,
                                listStyleType: "disc",
                                display: "grid",
                                gap: 6,
                              }}
                            >
                              <li>Added Light, Dark, and System options.</li>
                              <li>
                                Placed the toggle next to the Appearance
                                heading.
                              </li>
                            </ul>
                            <p>
                              Use the settings page to check each theme before
                              you ship.
                            </p>
                          </>
                        )}
                      </MessageContent>
                    </Message>
                  </MessageScrollerItem>
                ))}
              </MessageScrollerContent>
            </MessageScrollerViewport>
            <MessageScrollerButton />
          </MessageScroller>
        ) : (
          <Empty>
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <Icon name="MessageCircle" />
              </EmptyMedia>
              <EmptyTitle>What should we build?</EmptyTitle>
              <EmptyDescription>
                Describe a change to your code. Send a message to start the
                conversation.
              </EmptyDescription>
            </EmptyHeader>
          </Empty>
        )}
      </MessageScrollerProvider>
      <form onSubmit={send}>
        <InputGroup>
          <InputGroupTextarea
            aria-label="Message"
            placeholder="Ask the agent..."
            value={text}
            onChange={(event) => setText(event.target.value)}
            onKeyDown={(event) => {
              if (
                event.key === "Enter" &&
                !event.shiftKey &&
                !event.nativeEvent.isComposing
              ) {
                event.preventDefault();
                event.currentTarget.form?.requestSubmit();
              }
            }}
          />
          <InputGroupAddon align="block-end">
            <AiContextMenu onChoose={setContext} />
            <span style={{ flex: 1 }} />
            <InputGroupButton
              type="submit"
              variant={text.trim() ? "default" : "secondary"}
              size="icon-sm"
              aria-label="Send"
              aria-disabled={!text.trim()}
            >
              <Icon name="ArrowUp" />
            </InputGroupButton>
          </InputGroupAddon>
        </InputGroup>
      </form>
      <p
        role="status"
        style={{
          position: "absolute",
          width: 1,
          height: 1,
          padding: 0,
          margin: -1,
          overflow: "hidden",
          clipPath: "inset(50%)",
          whiteSpace: "nowrap",
          border: 0,
        }}
      >
        {context ? "Context: " + context : ""}
      </p>
    </div>
  );
}

Components and API

This composition has no separate component API. Use the APIs for its installed components.

ComponentTarget reference
message-scroller Base UI API
message Base UI API
bubble Base UI API
input-group Base UI API
button Base UI API
icon Base UI API
empty Base UI API
dropdown-menu Base UI API
tooltip Base UI API