"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { ArrowDownIcon, PanelLeftIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/ui/button";
import { Input } from "@/ui/input";
import { Separator } from "@/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/ui/sheet";
import { TooltipProvider } from "@/ui/tooltip";
// ============================================================================
// Sidekick Constants
// ============================================================================
const SIDEKICK_COOKIE_NAME = "sidekick_state";
const SIDEKICK_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEKICK_WIDTH = "24rem";
const SIDEKICK_WIDTH_MOBILE = "100%";
const SIDEKICK_WIDTH_COLLAPSED = "0rem";
const SIDEKICK_KEYBOARD_SHORTCUT = "i";
// ============================================================================
// Sidekick Context
// ============================================================================
type SidekickContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidekick: () => void;
};
const SidekickContext = React.createContext<SidekickContextProps | null>(null);
function useSidekick() {
const context = React.useContext(SidekickContext);
if (!context) {
throw new Error("useSidekick must be used within a SidekickProvider.");
}
return context;
}
// Optional hook that returns null if no provider is present (for standalone mode)
function useOptionalSidekick() {
return React.useContext(SidekickContext);
}
// ============================================================================
// Hook: useIsMobile
// ============================================================================
function useIsMobile() {
const [isMobile, setIsMobile] = React.useState(false);
React.useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
return isMobile;
}
// ============================================================================
// Sidekick Provider
// ============================================================================
type SidekickProviderProps = React.ComponentProps<"div"> & {
defaultOpen?: boolean;
defaultOpenMobile?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
};
function SidekickProvider({
defaultOpen = true,
defaultOpenMobile = false,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: SidekickProviderProps) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(defaultOpenMobile);
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
document.cookie = `${SIDEKICK_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEKICK_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
);
const toggleSidekick = React.useCallback(
() =>
isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open),
[isMobile, setOpen]
);
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEKICK_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidekick();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidekick]);
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidekickContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidekick,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidekick]
);
return (
<SidekickContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
className={cn("flex h-full min-h-svh w-full min-w-0", className)}
data-slot="sidekick-wrapper"
style={
{
"--sidekick-width": SIDEKICK_WIDTH,
"--sidekick-width-collapsed": SIDEKICK_WIDTH_COLLAPSED,
...style,
} as React.CSSProperties
}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidekickContext.Provider>
);
}
// ============================================================================
// Sidekick Panel
// ============================================================================
type SidekickProps = React.ComponentProps<"aside"> & {
side?: "left" | "right";
mobileBehavior?: "sheet" | "inline" | "floating";
/** When true, renders as a standalone panel without toggle behavior */
standalone?: boolean;
};
function Sidekick({
side = "right",
mobileBehavior = "sheet",
standalone = false,
className,
children,
...props
}: SidekickProps) {
const context = useOptionalSidekick();
// Standalone mode: render as a simple panel without provider
if (standalone || !context) {
return (
<aside
className={cn(
"flex h-full flex-col border-l bg-background text-foreground",
side === "left" && "border-r border-l-0",
className
)}
data-side={side}
data-slot="sidekick"
data-standalone="true"
style={
{
"--sidekick-width": SIDEKICK_WIDTH,
} as React.CSSProperties
}
{...props}
>
<div className="flex h-full w-full flex-col">{children}</div>
</aside>
);
}
const { isMobile, state, openMobile, setOpenMobile } = context;
if (isMobile && mobileBehavior === "sheet") {
return (
<Sheet onOpenChange={setOpenMobile} open={openMobile}>
<SheetContent
className="flex h-full w-full flex-col bg-background p-0 [&>button]:hidden"
data-mobile="true"
data-sidekick="panel"
data-slot="sidekick"
side={side}
style={
{
"--sidekick-width": SIDEKICK_WIDTH_MOBILE,
} as React.CSSProperties
}
>
<SheetHeader className="sr-only">
<SheetTitle>AI Assistant</SheetTitle>
<SheetDescription>Chat with your AI assistant.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
if (isMobile && mobileBehavior === "inline") {
return (
<aside
className={cn(
"flex w-full flex-col border-t bg-background text-foreground transition-[max-height,opacity] duration-200",
openMobile ? "max-h-[75vh] opacity-100" : "max-h-0 overflow-hidden opacity-0",
className
)}
data-side={side}
data-slot="sidekick"
data-state={openMobile ? "expanded" : "collapsed"}
{...props}
>
<div className="flex w-full flex-col">{children}</div>
</aside>
);
}
if (isMobile && mobileBehavior === "floating") {
return (
<aside
className={cn(
"absolute inset-y-0 right-0 flex w-full flex-col border-l bg-background text-foreground transition-transform duration-200",
openMobile ? "translate-x-0" : "translate-x-full",
className
)}
data-side={side}
data-slot="sidekick"
data-state={openMobile ? "expanded" : "collapsed"}
{...props}
>
<div className="flex h-full w-full flex-col">{children}</div>
</aside>
);
}
return (
<aside
className={cn(
"group peer hidden flex-col border-l bg-background text-foreground transition-[width] duration-200 ease-linear md:flex",
state === "expanded" ? "w-(--sidekick-width)" : "w-0 overflow-hidden",
side === "left" && "order-first border-r border-l-0",
className
)}
data-side={side}
data-slot="sidekick"
data-state={state}
{...props}
>
<div className="flex h-full w-(--sidekick-width) flex-col">
{children}
</div>
</aside>
);
}
// ============================================================================
// Sidekick Trigger
// ============================================================================
function SidekickTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const context = useOptionalSidekick();
// In standalone mode, trigger does nothing (or can be hidden)
if (!context) {
return null;
}
const { toggleSidekick } = context;
return (
<Button
className={cn("size-7", className)}
data-sidekick="trigger"
data-slot="sidekick-trigger"
onClick={(event) => {
onClick?.(event);
toggleSidekick();
}}
size="icon"
variant="ghost"
{...props}
>
<PanelLeftIcon className="size-4" />
<span className="sr-only">Toggle AI Assistant</span>
</Button>
);
}
// ============================================================================
// Sidekick Layout Components
// ============================================================================
function SidekickHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex h-14 shrink-0 items-center gap-2 border-b px-4",
className
)}
data-slot="sidekick-header"
{...props}
/>
);
}
function SidekickFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("shrink-0 border-t p-4", className)}
data-slot="sidekick-footer"
{...props}
/>
);
}
function SidekickContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex min-h-0 flex-1 flex-col overflow-hidden", className)}
data-slot="sidekick-content"
{...props}
/>
);
}
function SidekickInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
className={cn("relative flex min-w-0 flex-1 flex-col", className)}
data-slot="sidekick-inset"
{...props}
/>
);
}
function SidekickSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
className={cn("mx-2 w-auto", className)}
data-slot="sidekick-separator"
{...props}
/>
);
}
function SidekickInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
className={cn("h-8 w-full bg-background shadow-none", className)}
data-slot="sidekick-input"
{...props}
/>
);
}
// ============================================================================
// Conversation Components
// ============================================================================
type ConversationContextProps = {
scrollToBottom: () => void;
isAtBottom: boolean;
};
const ConversationContext =
React.createContext<ConversationContextProps | null>(null);
function useConversation() {
const context = React.useContext(ConversationContext);
if (!context) {
throw new Error("useConversation must be used within a Conversation.");
}
return context;
}
type ConversationProps = React.ComponentProps<"div">;
function Conversation({ className, children, ...props }: ConversationProps) {
const scrollRef = React.useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = React.useState(true);
const scrollToBottom = React.useCallback(() => {
if (scrollRef.current) {
scrollRef.current.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
}
}, []);
const handleScroll = React.useCallback(() => {
if (scrollRef.current) {
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
setIsAtBottom(scrollHeight - scrollTop - clientHeight < 50);
}
}, []);
React.useEffect(() => {
const scrollEl = scrollRef.current;
if (scrollEl) {
scrollEl.addEventListener("scroll", handleScroll);
return () => scrollEl.removeEventListener("scroll", handleScroll);
}
}, [handleScroll]);
// Auto-scroll when children change and user is at bottom
React.useEffect(() => {
if (isAtBottom) {
scrollToBottom();
}
}, [children, isAtBottom, scrollToBottom]);
const contextValue = React.useMemo(
() => ({ scrollToBottom, isAtBottom }),
[scrollToBottom, isAtBottom]
);
return (
<ConversationContext.Provider value={contextValue}>
<div
className={cn("relative flex-1 overflow-y-auto", className)}
data-slot="conversation"
ref={scrollRef}
role="log"
{...props}
>
{children}
</div>
</ConversationContext.Provider>
);
}
function ConversationContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-col gap-4 p-4", className)}
data-slot="conversation-content"
{...props}
/>
);
}
type ConversationEmptyStateProps = React.ComponentProps<"div"> & {
title?: string;
description?: string;
icon?: React.ReactNode;
};
function ConversationEmptyState({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) {
return (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className
)}
data-slot="conversation-empty-state"
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
}
function ConversationScrollButton({
className,
...props
}: React.ComponentProps<typeof Button>) {
const { isAtBottom, scrollToBottom } = useConversation();
if (isAtBottom) return null;
return (
<Button
className={cn(
"-translate-x-1/2 absolute bottom-4 left-1/2 rounded-full",
className
)}
data-slot="conversation-scroll-button"
onClick={scrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
);
}
// ============================================================================
// Message Components
// ============================================================================
const messageVariants = cva("flex w-full gap-3", {
variants: {
from: {
user: "flex-row-reverse",
assistant: "flex-row",
},
},
defaultVariants: {
from: "assistant",
},
});
type MessageProps = React.ComponentProps<"div"> &
VariantProps<typeof messageVariants>;
function Message({ className, from, children, ...props }: MessageProps) {
return (
<div
className={cn(messageVariants({ from }), className)}
data-from={from}
data-slot="message"
{...props}
>
{children}
</div>
);
}
const messageContentVariants = cva(
"max-w-[85%] rounded-2xl px-4 py-2.5 text-sm",
{
variants: {
from: {
user: "rounded-br-md bg-primary text-primary-foreground",
assistant: "rounded-bl-md bg-muted text-foreground",
},
},
defaultVariants: {
from: "assistant",
},
}
);
type MessageContentProps = React.ComponentProps<"div"> &
VariantProps<typeof messageContentVariants>;
function MessageContent({ className, from, ...props }: MessageContentProps) {
return (
<div
className={cn(messageContentVariants({ from }), className)}
data-from={from}
data-slot="message-content"
{...props}
/>
);
}
function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full bg-muted",
className
)}
data-slot="message-avatar"
{...props}
/>
);
}
function MessageTimestamp({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
className={cn("text-muted-foreground text-xs", className)}
data-slot="message-timestamp"
{...props}
/>
);
}
function MessageActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100",
className
)}
data-slot="message-actions"
{...props}
/>
);
}
// ============================================================================
// Exports
// ============================================================================
export {
// Sidekick
Sidekick,
SidekickContent,
SidekickFooter,
SidekickHeader,
SidekickInset,
SidekickInput,
SidekickProvider,
SidekickSeparator,
SidekickTrigger,
useSidekick,
// Conversation
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
useConversation,
// Message
Message,
MessageActions,
MessageAvatar,
MessageContent,
MessageTimestamp,
};
// ============================================================================
// Compound Component Exports
// ============================================================================
Sidekick.Header = SidekickHeader;
Sidekick.Content = SidekickContent;
Sidekick.Footer = SidekickFooter;
Sidekick.Inset = SidekickInset;
Sidekick.Input = SidekickInput;
Sidekick.Separator = SidekickSeparator;
Sidekick.Trigger = SidekickTrigger;
Sidekick.Provider = SidekickProvider;
Overview
Sidekick is a composable chat layout system. It ships as a sidebar with optional toggle behavior, a conversation container with auto-scroll, and a set of message primitives you can style or extend. Use it standalone or wrap it in SidekickProvider for keyboard shortcuts, responsive behavior, and an inset layout area.
Installation
bunx --bun shadcn@latest add https://sidekick.montek.dev/r/sidekick.jsonUsage
import {
Sidekick,
SidekickContent,
SidekickFooter,
SidekickHeader,
SidekickInset,
SidekickProvider,
SidekickTrigger,
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
Message,
MessageActions,
MessageAvatar,
MessageContent,
MessageTimestamp,
} from "@/components/sidekick";Provider + Toggleable Panel
Use SidekickProvider to enable toggling, keyboard shortcuts (⌘I / Ctrl+I), and responsive layouts.
<SidekickProvider defaultOpen>
<SidekickInset>
{/* Main content */}
<SidekickTrigger />
</SidekickInset>
<Sidekick>
<SidekickHeader className="justify-between">
<span className="font-semibold">AI Assistant</span>
<SidekickTrigger />
</SidekickHeader>
<SidekickContent>
<Conversation>
<ConversationContent>
<ConversationEmptyState />
</ConversationContent>
<ConversationScrollButton />
</Conversation>
</SidekickContent>
<SidekickFooter>{/* Add PromptInput here */}</SidekickFooter>
</Sidekick>
</SidekickProvider>Standalone Mode
Use Sidekick as a fixed panel (no provider, no toggle behavior):
<Sidekick standalone>
<SidekickHeader>
<span className="font-semibold">AI Assistant</span>
</SidekickHeader>
<SidekickContent>
<Conversation>
<ConversationContent>
<Message from="user">
<MessageContent from="user">Hello!</MessageContent>
</Message>
<Message from="assistant">
<MessageContent from="assistant">Hi there!</MessageContent>
</Message>
</ConversationContent>
</Conversation>
</SidekickContent>
<SidekickFooter>{/* Add PromptInput here */}</SidekickFooter>
</Sidekick>Mobile Behavior
Sidekick supports three mobile behaviors:
sheet(default): full-screen sheet on mobileinline: collapsible section within the layoutfloating: off-canvas panel that slides in
<SidekickProvider defaultOpenMobile>
<Sidekick mobileBehavior="floating">...</Sidekick>
</SidekickProvider>Composition Patterns
Prompt Input Toolbars
Compose any PromptInput layout inside SidekickFooter—stack headers, chips, and inline buttons.
<SidekickFooter>
<PromptInput variant="outline" size="lg" onSubmit={handleSubmit}>
<PromptInput.Header>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="whitespace-nowrap">Tone: Confident</span>
<span className="whitespace-nowrap">Audience: Execs</span>
</div>
</PromptInput.Header>
<PromptInput.Body>
<PromptInput.Textarea minRows={4} placeholder="Draft the launch recap..." />
<PromptInput.Button>
<MicIcon className="size-4" />
</PromptInput.Button>
</PromptInput.Body>
<PromptInput.Footer className="flex-col gap-3">
<PromptInput.Tools className="gap-2">
<PromptInput.Button className="gap-1 px-3">
<SparklesIcon className="size-4" />
<span>Rewrite</span>
</PromptInput.Button>
<PromptInput.Button className="gap-1 px-3">
<GlobeIcon className="size-4" />
<span>Search Docs</span>
</PromptInput.Button>
</PromptInput.Tools>
<PromptInput.Submit status={status} />
</PromptInput.Footer>
</PromptInput>
</SidekickFooter>Add PromptInputProvider around Sidekick when presets, external buttons, or docking menus need the same input state.
Persisting Conversations
Conversation renders whatever list you feed it. Store messages with Drizzle/Prisma and hydrate them when the sidebar mounts.
const messages = await db.query.messages.findMany({
where: eq(messages.threadId, params.threadId),
orderBy: asc(messages.createdAt),
})
return (
<Conversation>
<ConversationContent>
{messages.map((msg) => (
<Message className="group" from={msg.from} key={msg.id}>
<MessageAvatar />
<div className="space-y-1">
<MessageContent from={msg.from}>{msg.body}</MessageContent>
<div className="flex items-center gap-2">
<MessageTimestamp>{formatTime(msg.createdAt)}</MessageTimestamp>
<MessageActions>
{/* Copy / react / pin */}
</MessageActions>
</div>
</div>
</Message>
))}
</ConversationContent>
</Conversation>
)Features
- Collapsible Panel: Toggle the sidebar with keyboard shortcut (
⌘I/Ctrl+I) - Mobile Modes:
sheet,inline, orfloating - Conversation History: Auto-scrolling message list with scroll-to-bottom button
- Message Bubbles: Styled user/assistant bubbles with optional avatars
- Empty State: Customizable empty state when no messages exist
- Keyboard Navigation: Full keyboard accessibility
Components
SidekickProvider
Wraps your app and provides the sidebar state context.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultOpen | boolean | true | Initial open state |
defaultOpenMobile | boolean | false | Initial mobile open state |
open | boolean | - | Controlled open state |
onOpenChange | (open: boolean) => void | - | Callback when open state changes |
Sidekick
The sidebar panel component.
| Prop | Type | Default | Description |
|---|---|---|---|
side | "left" | "right" | "right" | Which side the panel appears on |
mobileBehavior | "sheet" | "inline" | "floating" | "sheet" | Mobile rendering mode |
standalone | boolean | false | When true, renders as a fixed panel without toggle behavior |
SidekickTrigger
A button to toggle the sidebar open/closed. Returns null in standalone mode.
SidekickHeader
The header section of the sidebar.
SidekickContent
The main content area of the sidebar (typically contains Conversation).
SidekickFooter
The footer section (typically contains PromptInput).
SidekickInset
The main content area that sits beside the sidebar.
SidekickSeparator
A styled separator for toolbars and lists.
SidekickInput
A compact input field for filters or search inside the sidebar.
Conversation
Container for the message list with auto-scroll functionality.
ConversationContent
The scrollable content area inside Conversation.
ConversationScrollButton
A floating scroll-to-bottom button that appears when the user is not at the end.
ConversationEmptyState
Displayed when there are no messages.
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | "No messages yet" | Title text |
description | string | - | Description text |
icon | ReactNode | - | Icon to display |
Message
Container for a single message.
| Prop | Type | Default | Description |
|---|---|---|---|
from | "user" | "assistant" | "assistant" | Who sent the message |
MessageContent
The message bubble with text content.
| Prop | Type | Default | Description |
|---|---|---|---|
from | "user" | "assistant" | "assistant" | Styles the bubble based on sender |
MessageAvatar
Container for the sender's avatar.
MessageTimestamp
Text-only timestamp styling.
MessageActions
An actions row that fades in on hover. Add className="group" to the parent Message to enable hover behavior.
Hooks
useSidekick
Access the sidebar state and controls.
const { state, open, setOpen, toggleSidekick } = useSidekick();useConversation
Access conversation scroll controls.
const { scrollToBottom, isAtBottom } = useConversation();Theming
Sidekick supports theming through CSS classes. Apply a theme by adding the theme class to your provider:
Custom Themes
Create your own theme by targeting the data attributes:
.my-theme [data-slot="sidekick"] {
background-color: #1a1a1a;
}
.my-theme [data-slot="message-content"][data-from="user"] {
background-color: #0078d4;
}
.my-theme [data-slot="message-content"][data-from="assistant"] {
background-color: #2d2d2d;
}On This Page
OverviewInstallationUsageProvider + Toggleable PanelStandalone ModeMobile BehaviorComposition PatternsPrompt Input ToolbarsPersisting ConversationsFeaturesComponentsSidekickProviderSidekickSidekickTriggerSidekickHeaderSidekickContentSidekickFooterSidekickInsetSidekickSeparatorSidekickInputConversationConversationContentConversationScrollButtonConversationEmptyStateMessageMessageContentMessageAvatarMessageTimestampMessageActionsHooksuseSidekickuseConversationThemingCustom Themes