Plan a new screen
Chat shell
FreeThe frame of a chat screen: sidebar of chats, header, transcript and composer.
pnpm dlx shadcn@latest add https://caliperui.site/r/chat-shell.jsonPlan a new screen
Assistant
Here is a short answer. Start with the smallest change, check it with the keyboard, then add polish.
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.