diff --git a/ui/src/components/SideDrawer.module.css b/ui/src/components/SideDrawer.module.css
new file mode 100644
index 00000000..aa5271ca
--- /dev/null
+++ b/ui/src/components/SideDrawer.module.css
@@ -0,0 +1,77 @@
+.root {
+ position: fixed;
+ inset: 0;
+ z-index: 950;
+ pointer-events: none;
+}
+
+.backdrop {
+ position: absolute;
+ inset: 0;
+ pointer-events: auto;
+ /* transparent — no dim */
+}
+
+.drawer {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ background: var(--bg-surface);
+ border-left: 1px solid var(--border);
+ box-shadow: var(--shadow-lg);
+ display: flex;
+ flex-direction: column;
+ pointer-events: auto;
+ animation: slideIn 240ms ease-out;
+}
+
+.size-md { width: 560px; max-width: 100vw; }
+.size-lg { width: 720px; max-width: 100vw; }
+.size-xl { width: 900px; max-width: 100vw; }
+
+.header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ padding: 14px 18px;
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.title {
+ flex: 1;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.closeBtn {
+ background: transparent;
+ border: 0;
+ font-size: 24px;
+ line-height: 1;
+ color: var(--text-muted);
+ cursor: pointer;
+ padding: 0 4px;
+}
+
+.closeBtn:hover { color: var(--text-primary); }
+
+.body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px 18px;
+ color: var(--text-primary);
+}
+
+.footer {
+ flex-shrink: 0;
+ padding: 14px 18px;
+ border-top: 1px solid var(--border);
+ background: var(--bg-inset);
+}
+
+@keyframes slideIn {
+ from { transform: translateX(100%); }
+ to { transform: translateX(0); }
+}
diff --git a/ui/src/components/SideDrawer.test.tsx b/ui/src/components/SideDrawer.test.tsx
new file mode 100644
index 00000000..72491a48
--- /dev/null
+++ b/ui/src/components/SideDrawer.test.tsx
@@ -0,0 +1,53 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { ThemeProvider } from '@cameleer/design-system';
+import type { ReactNode } from 'react';
+import { SideDrawer } from './SideDrawer';
+
+function wrap(ui: ReactNode) {
+ return render({ui});
+}
+
+describe('SideDrawer', () => {
+ it('renders nothing when closed', () => {
+ wrap( {}} title="X">body);
+ expect(screen.queryByText('body')).toBeNull();
+ });
+
+ it('renders title, body, and close button when open', () => {
+ wrap( {}} title="My Title">body content);
+ expect(screen.getByText('My Title')).toBeInTheDocument();
+ expect(screen.getByText('body content')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
+ });
+
+ it('calls onClose when close button clicked', () => {
+ const onClose = vi.fn();
+ wrap(body);
+ fireEvent.click(screen.getByRole('button', { name: /close/i }));
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+
+ it('calls onClose when ESC pressed', () => {
+ const onClose = vi.fn();
+ wrap(body);
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+
+ it('calls onClose when backdrop clicked', () => {
+ const onClose = vi.fn();
+ wrap(body);
+ fireEvent.click(screen.getByTestId('side-drawer-backdrop'));
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+
+ it('renders footer when provided', () => {
+ wrap(
+ {}} title="X" footer={}>
+ body
+
+ );
+ expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
+ });
+});
diff --git a/ui/src/components/SideDrawer.tsx b/ui/src/components/SideDrawer.tsx
new file mode 100644
index 00000000..64179567
--- /dev/null
+++ b/ui/src/components/SideDrawer.tsx
@@ -0,0 +1,55 @@
+import { useEffect, type ReactNode } from 'react';
+import { createPortal } from 'react-dom';
+import styles from './SideDrawer.module.css';
+
+interface SideDrawerProps {
+ open: boolean;
+ onClose: () => void;
+ title: ReactNode;
+ size?: 'md' | 'lg' | 'xl';
+ footer?: ReactNode;
+ children: ReactNode;
+}
+
+export function SideDrawer({
+ open, onClose, title, size = 'lg', footer, children,
+}: SideDrawerProps): React.JSX.Element | null {
+ useEffect(() => {
+ if (!open) return;
+ const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
+ document.addEventListener('keydown', handler);
+ return () => document.removeEventListener('keydown', handler);
+ }, [open, onClose]);
+
+ if (!open) return null;
+
+ return createPortal(
+
+
+
+
,
+ document.body
+ );
+}