About
A chat that sits beside your app in three modes: a minimised pill, a corner tab that peeks on hover, and a resizable dock.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Chat Surface to your project and fits it to your theme.
Code
How Chat Surface is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
'use client';
import * as React from 'react';
import { ArrowUp, FileText } from 'lucide-react';
import { ChatSurface, type ChatSurfaceMode } from '@/components/ui/chat-surface';
const FILES = [
{ name: 'Quarterly report.pdf', meta: '24 pages · edited 2 h ago' },
{ name: 'Vendor agreement.docx', meta: '11 pages · edited yesterday' },
{ name: 'Board deck.pdf', meta: '38 pages · edited Monday' },
{ name: 'Hiring plan.xlsx', meta: '3 sheets · edited last week' },
{ name: 'Launch checklist.md', meta: '1 page · edited last week' },
];
function Workspace({ mode }: { mode: ChatSurfaceMode }) {
return (
<div className="mx-auto flex max-w-2xl flex-col gap-6 p-6 sm:p-10">
<div className="flex flex-col gap-1">
<h1 className="text-xl font-semibold tracking-tight">Documents</h1>
<p className="text-sm text-muted-foreground">
Chat is in <span className="font-medium text-foreground">{mode}</span> mode.{' '}
{mode === 'peek'
? 'Hover the corner tab to peek, click it to keep it open.'
: mode === 'pill'
? 'Click the pill or press ⌘J to bring it back.'
: 'Drag the grip on its edge to resize.'}
</p>
</div>
<ul className="divide-y divide-border rounded-xl border border-border bg-card text-card-foreground">
{FILES.map((file) => (
<li key={file.name} className="flex items-center gap-3 px-4 py-3">
<FileText aria-hidden="true" className="size-4 flex-none text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-sm font-medium">{file.name}</span>
<span className="hidden text-xs text-muted-foreground sm:block">{file.meta}</span>
</li>
))}
</ul>
</div>
);
}
interface Message {
role: 'user' | 'assistant';
text: string;
}
const STARTER: Message[] = [
{ role: 'user', text: 'What changed in the vendor agreement?' },
{
role: 'assistant',
text: 'Two things: the payment terms moved from net 30 to net 45, and the renewal now needs 60 days’ notice instead of 30.',
},
];
function Conversation({ messages, onSend }: { messages: Message[]; onSend: (text: string) => void }) {
const [draft, setDraft] = React.useState('');
const send = (event: React.FormEvent) => {
event.preventDefault();
if (!draft.trim()) return;
onSend(draft.trim());
setDraft('');
};
return (
<>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-4 py-2">
{messages.length === 0 ? (
<p className="m-auto text-sm text-muted-foreground">Ask anything about your documents.</p>
) : (
messages.map((message, i) =>
message.role === 'user' ? (
<p key={i} className="max-w-[85%] self-end rounded-2xl bg-primary px-3 py-2 text-sm text-primary-foreground">
{message.text}
</p>
) : (
<p key={i} className="max-w-[95%] text-sm leading-6">
{message.text}
</p>
)
)
)}
</div>
<form onSubmit={send} className="flex-none p-3">
<div className="flex items-end gap-2 rounded-xl border border-border bg-background p-2 focus-within:ring-2 focus-within:ring-ring">
<textarea
rows={1}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) send(event);
}}
placeholder="Ask a question…"
aria-label="Message"
className="max-h-32 min-h-8 flex-1 resize-none bg-transparent px-1 py-1.5 text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
<button
type="submit"
aria-label="Send"
disabled={!draft.trim()}
className="inline-flex size-8 flex-none items-center justify-center rounded-lg bg-primary text-primary-foreground transition-opacity disabled:opacity-40"
>
<ArrowUp aria-hidden="true" className="size-4" />
</button>
</div>
</form>
</>
);
}
function ChatSurfaceDemo({ defaultMode, unread }: { defaultMode: ChatSurfaceMode; unread?: boolean }) {
const [mode, setMode] = React.useState<ChatSurfaceMode>(defaultMode);
const [messages, setMessages] = React.useState<Message[]>(STARTER);
const send = (text: string) =>
setMessages((current) => [
...current,
{ role: 'user', text },
{ role: 'assistant', text: 'This is a demo, so there is no model behind it, but your message would be answered here.' },
]);
return (
<div className="h-[100dvh] min-h-[560px] w-full">
<ChatSurface
mode={mode}
onModeChange={setMode}
unread={unread && mode === 'pill'}
onNewChat={() => setMessages([])}
panel={<Conversation messages={messages} onSend={send} />}
>
<Workspace mode={mode} />
</ChatSurface>
</div>
);
}
export default function Demo() {
return <ChatSurfaceDemo defaultMode="peek" />;
}
