UX Standards — 12: Popout and Slide-Over Windows
Governs: Persistent popup windows (policy pages, help docs) and Sheet slide-over panels.
Parent rules: See 00-OVERVIEW-AND-CSS-RULES.md first.
Two Patterns
| Pattern | When to Use | Component |
|---|---|---|
| Persistent Popup Window | External page content that stays open alongside the app (policy pages, help docs, print views) | openPersistentPopup() utility |
| Sheet Slide-Over | In-app side panels: detail views, filters, mobile nav, forms that don't require full context | shadcn Sheet |
Pattern 1 — Persistent Popup Window
Used for policy documents and help pages that the user may want to reference while continuing to work.
// packages/shared/lib/popup/persistent-popup.ts
export function openPersistentPopup(
type: string, // Unique key to prevent duplicate windows ('policy', 'help')
path: string, // App route to open in the popup ('/policies/privacy')
windowName: string // Named window target ('policy-window', 'help-window')
): void {
const url = `${window.location.origin}${path}`;
const features = 'width=800,height=600,scrollbars=yes,resizable=yes';
window.open(url, windowName, features);
}
Footer Policy Links
// In the footer:
const openPolicyWindow = useCallback((path: string) => {
openPersistentPopup('policy', path, 'policy-window');
}, []);
<button
type="button"
onClick={() => openPolicyWindow('/policies/privacy')}
className="cursor-pointer border-none bg-transparent p-0 hover:text-primary"
>
Privacy Policy
</button>
Window behavior:
- Opens in a separate named browser window (not a tab, not an iframe)
- If the window is already open (
windowNamematches), it is brought to focus instead of opening a new one - Window size: 800×600px, scrollable, resizable
- The popup renders the app route in a simplified layout (no sidebar, no header nav)
Popup Layout
Policy and help pages opened in a popup use a stripped layout:
// app/(popup)/policies/[type]/page.tsx
// No sidebar, no sticky header, no footer nav
// Simple scrollable content with the policy text
<div className="max-w-3xl mx-auto px-6 py-8 prose dark:prose-invert">
{/* Policy content */}
</div>
Pattern 2 — Sheet Slide-Over
Used for in-app panels that slide in from the edge without navigating away.
Basic Sheet
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="outline">Open Panel</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[400px] sm:w-[540px]">
<SheetHeader>
<SheetTitle>Panel Title</SheetTitle>
<SheetDescription>Panel description</SheetDescription>
</SheetHeader>
<div className="py-4">
{/* Panel content */}
</div>
</SheetContent>
</Sheet>
Sheet Sizes
| Use Case | Width | Side |
|---|---|---|
| Mobile navigation | w-[280px] | left |
| Detail view | w-[400px] sm:w-[540px] | right |
| Edit form | w-[400px] sm:w-[540px] | right |
| Wide detail / preview | sm:max-w-xl | right |
| Filter panel | w-[320px] | right |
Mobile Navigation Sheet (Accelerate)
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Open menu">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[280px] p-0 flex flex-col">
<SheetHeader className="px-6 py-5 border-b">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-primary text-primary-foreground flex items-center justify-center text-lg font-semibold">
{APP_INITIAL}
</div>
<div>
<SheetTitle className="text-base font-semibold">{APP_NAME}</SheetTitle>
<p className="text-xs text-muted-foreground">{APP_RELEASE_VERSION}</p>
</div>
</div>
</SheetHeader>
<NavigationContent />
</SheetContent>
</Sheet>
Key rules for mobile nav Sheet:
p-0on SheetContent (logo header manages its own padding)flex flex-colon SheetContent- Close trigger: route change via
useEffectwatchingpathname - Width:
w-[280px](slightly wider than desktop 256px for touch)
Record Detail Sheet
<Sheet>
<SheetContent side="right" className="sm:max-w-xl flex flex-col">
<SheetHeader>
<SheetTitle>{record.name}</SheetTitle>
<SheetDescription>Record ID: {record.id}</SheetDescription>
</SheetHeader>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto py-4 space-y-4">
{/* Fields, timeline, actions */}
</div>
{/* Fixed footer */}
<div className="border-t pt-4 flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)}>Close</Button>
<Button onClick={handleEdit}>Edit</Button>
</div>
</SheetContent>
</Sheet>
Sheet vs Dialog — Decision Guide
| Scenario | Use |
|---|---|
| Mobile navigation | Sheet (left side) |
| Record detail/preview (stays open while user works) | Sheet (right side) |
| Filter/sort configuration panel | Sheet (right side) |
| Destructive confirmation | Dialog (AlertDialog) |
| Create/edit form | Dialog (medium size) |
| Import wizard | Dialog (large size) |
| Policy/help documents | Persistent popup window |
| Error messages | Dialog (small) |
| Full-page focused flow (no sidebar reference needed) | Dialog (large or full) |
Resizable Dialog
For dialogs where the user may want to resize (e.g., record detail preview with long content):
// resizable-dialog.tsx — used in referral-automation
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ backdropFilter: 'blur(4px)' }}
>
<div
className="bg-card rounded-lg border shadow-lg overflow-hidden flex flex-col"
style={{ width: dialogWidth, height: dialogHeight, resize: 'both' }}
>
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-lg font-semibold">{title}</h2>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{children}
</div>
</div>
</div>
Use sparingly — prefer fixed-size dialogs. Resizable dialogs are only appropriate for content-heavy detail views where the user needs to see more data.
Accessibility Rules for Popups and Sheets
| Rule | Implementation |
|---|---|
| Focus trap | shadcn Sheet and Dialog handle this automatically via Radix UI |
| Escape closes | shadcn handles: onKeyDown for Escape |
| Return focus | Focus returns to trigger element on close |
| Screen reader announcement | SheetTitle and DialogTitle are required — they become the aria-label |
| Background scroll lock | Applied automatically when Sheet/Dialog is open |
Pattern 3 — Record Popup Window (/popup/<doctype>/<id>)
Some app implementations support opening individual records in a dedicated floating browser window for side-by-side comparison. This uses a special /popup/ route.
URL Pattern
/popup/<DocTypeName>/<RecordId>?popupId=<uuid>
Example:
/popup/Product%20Type/ADVENTURE?popupId=52ea421d-e6cb-4cba-a528-450745b946cc
Window Behavior
// Triggered from row actions → "View" or "Open in popup"
const openRecordPopup = (doctype: string, id: string) => {
const popupId = crypto.randomUUID();
const url = `/popup/${encodeURIComponent(doctype)}/${encodeURIComponent(id)}?popupId=${popupId}`;
window.open(url, `popup-${popupId}`, 'width=800,height=600,scrollbars=yes,resizable=yes');
};
"Unsupported Document Type" Error State
When a DocType does not yet have a popup implementation, the /popup/ route shows an error:
┌─────────────────────────────────────────────────────┐
│ ⚠ Unsupported Document Type │
│ The document type "Product Type" is not yet │
│ supported in popup mode. │
└─────────────────────────────────────────────────────┘
Implementation:
// Inside the /popup/[doctype]/[id]/page.tsx:
const SUPPORTED_POPUP_DOCTYPES = ['User', 'Role', 'Permission']; // expand as implemented
if (!SUPPORTED_POPUP_DOCTYPES.includes(doctype)) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Unsupported Document Type</AlertTitle>
<AlertDescription>
The document type "{doctype}" is not yet supported in popup mode.
</AlertDescription>
</Alert>
);
}
Component: shadcn/ui Alert with variant="destructive", AlertCircle icon, AlertTitle + AlertDescription.
This error is expected for new doctypes that haven't yet had their popup view built. It is not a crash — the window opens and shows the alert, allowing the developer to know what needs to be implemented.
Violation Checklist
- Policy links use
openPersistentPopup('policy', path, 'policy-window')— never<a target="_blank"> - Mobile nav uses
Sheetwithside="left" w-[280px] p-0 flex flex-col - Sheet closes on route change (useEffect watching pathname)
-
SheetTitleis always present (screen reader requirement) - Sheet sizes follow the size table (
w-[280px]mobile nav,sm:max-w-xldetail) - Detail sheets have
flex flex-col+flex-1 overflow-y-autobody + fixed footer - No custom
z-indexoverrides — rely on shadcn's z-50 - No
iframefor policy pages — useopenPersistentPopuputility - Record popup URLs use
/popup/<doctype>/<id>?popupId=<uuid>pattern - Unsupported doctype in popup shows
Alert variant="destructive"(not a crash page) - "Unsupported Document Type" alert uses
AlertCircleicon +AlertTitle+AlertDescription