caliper uidocs

Chat shell

Free

The frame of a chat screen: sidebar of chats, header, transcript and composer.

Installs components/ui/chat-shell.tsxDepends on @radix-ui/react-slot, lucide-reactMotion none in component source
pnpm dlx shadcn@latest add https://caliperui.site/r/chat-shell.json

Plan a new screen

Plan a new screen

Assistant

Here is a short answer. Start with the smallest change, check it with the keyboard, then add polish.

Enter to send · Shift+Enter for a new line

When to use #

Use it for

  • Building a full-page assistant with several conversations.
  • You want the layout to collapse for narrow screens.

Skip it when

  • The assistant is a small panel; use the assistant panel.

Installation #

terminal
pnpm dlx shadcn@latest add https://caliperui.site/r/chat-shell.json

Adds its 3 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.

Usage #

This is the code behind the preview above.

example.tsx
import { MessageSquarePlus } from "lucide-react"
import { useRef, useState } from "react"
import { Button } from "@/components/ui/button"
import { ChatShell } from "@/components/ui/chat-shell"
import { Message } from "@/components/ui/message"
import { MessageScroller } from "@/components/ui/message-scroller"
import { PromptInput } from "@/components/ui/prompt-input"
import { Search } from "@/components/ui/search"
import { SharedHighlight, SharedHighlightItem } from "@/components/ui/shared-highlight"

type Turn = { id: string; role: "user" | "assistant"; text: string; status?: "pending" | "complete" };

type Chat = { id: string; title: string; turns: Turn[] };

const answers = [
  "Here is a short answer. Start with the smallest change, check it with the keyboard, then add polish.",
  "Good question. I would keep one source of truth and let the components stay controlled.",
  "That depends on the screen. If you tell me where it lives, I can be more specific.",
];

function Turns({ turns }: { turns: Turn[] }) {
  return (
    <>
      {turns.map((t) => (
        <Message key={t.id} role={t.role} name={t.role === "assistant" ? "Assistant" : undefined} status={t.status === "pending" ? "pending" : "complete"}>
          {t.text || null}
        </Message>
      ))}
    </>
  );
}

export function ChatShellDemo() {
  const [chats, setChats] = useState<Chat[]>([
      { id: "c1", title: "Plan a new screen", turns: [{ id: "x1", role: "user", text: "Plan a new screen" }, { id: "x2", role: "assistant", text: answers[0], status: "complete" }] },
      { id: "c2", title: "Review the table change", turns: [{ id: "y1", role: "user", text: "Review the table change" }, { id: "y2", role: "assistant", text: answers[1], status: "complete" }] },
      { id: "c3", title: "Release notes draft", turns: [] },
    ]);
  const [active, setActive] = useState("c1");
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
  const chat = chats.find((c) => c.id === active)!;
  const visible = chats.filter((c) => c.title.toLowerCase().includes(query.toLowerCase()));
  const patch = (id: string, fn: (t: Turn[]) => Turn[]) => setChats((all) => all.map((c) => (c.id === id ? { ...c, turns: fn(c.turns) } : c)));
  const send = (text: string) => {
      const id = active,
        stamp = Date.now();
      setChats((all) => all.map((c) => (c.id === id ? { ...c, title: c.turns.length ? c.title : text.slice(0, 32), turns: [...c.turns, { id: `u${stamp}`, role: "user", text }, { id: `a${stamp}`, role: "assistant", text: "", status: "pending" }] } : c)));
      timers.current.push(setTimeout(() => patch(id, (t) => t.map((x) => (x.id === `a${stamp}` ? { ...x, text: answers[stamp % answers.length], status: "complete" } : x))), 1100));
    };
  return (
    <ChatShell
            sidebarOpen={open}
            onSidebarOpenChange={setOpen}
            sidebar={
              <>
                <div className="grid gap-2 p-3">
                  <Button size="sm" onClick={() => { const id = `c${chats.length + 1}-${chats.length}`; setChats([{ id, title: "New chat", turns: [] }, ...chats]); setActive(id); setOpen(false); }}>
                    <MessageSquarePlus aria-hidden="true" /> New chat
                  </Button>
                  <Search aria-label="Search conversations" placeholder="Search" value={query} onChange={(e) => setQuery(e.target.value)} onClear={() => setQuery("")} />
                </div>
                <SharedHighlight value={active} onValueChange={(id) => { setActive(id); setOpen(false); }} className="grid flex-1 content-start gap-0.5 overflow-auto px-2 pb-2">
                  {visible.map((c) => (
                    <SharedHighlightItem key={c.id} value={c.id} as="button" type="button" className="truncate px-3 py-2 text-left text-sm">
                      {c.title}
                    </SharedHighlightItem>
                  ))}
                  {!visible.length && <p className="px-3 py-2 text-sm text-muted-foreground">No conversations match.</p>}
                </SharedHighlight>
              </>
            }
            header={<h3 className="truncate text-sm font-medium">{chat.title}</h3>}
            composer={<PromptInput key={active} allowFiles={false} placeholder="Message the assistant" onSubmit={({ text }) => send(text)} />}
          >
            <MessageScroller label={chat.title} followKey={`${active}-${chat.turns.length}`}>
              {chat.turns.length ? <Turns turns={chat.turns} /> : <p className="grid min-h-40 place-items-center text-sm text-muted-foreground">Start the conversation below.</p>}
            </MessageScroller>
          </ChatShell>
  );
}

API reference #

ChatShell

component
PropTypeDefaultDescription
sidebar *ReactNode—
headerReactNode—Title area for the current conversation.
children *ReactNode—
composer *ReactNode—
sidebarOpen *boolean—Only affects narrow screens, where the list is a drawer.
onSidebarOpenChange *(open: boolean) => void—
classNamestring—

Accessibility #

It is built on native elements and keeps aria-hidden, aria-label and aria-expanded in step with state.

KeyBehaviour
EscapeCloses or cancels, then returns focus to where it came from.

Motion #

It declares no animation in its component source. Any transition comes from its stylesheet, which uses the duration tokens that collapse to instant under prefers-reduced-motion.