Skip to main content

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

PatternWhen to UseComponent
Persistent Popup WindowExternal page content that stays open alongside the app (policy pages, help docs, print views)openPersistentPopup() utility
Sheet Slide-OverIn-app side panels: detail views, filters, mobile nav, forms that don't require full contextshadcn 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);
}
// 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 (windowName matches), 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)

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 CaseWidthSide
Mobile navigationw-[280px]left
Detail vieww-[400px] sm:w-[540px]right
Edit formw-[400px] sm:w-[540px]right
Wide detail / previewsm:max-w-xlright
Filter panelw-[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-0 on SheetContent (logo header manages its own padding)
  • flex flex-col on SheetContent
  • Close trigger: route change via useEffect watching pathname
  • 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

ScenarioUse
Mobile navigationSheet (left side)
Record detail/preview (stays open while user works)Sheet (right side)
Filter/sort configuration panelSheet (right side)
Destructive confirmationDialog (AlertDialog)
Create/edit formDialog (medium size)
Import wizardDialog (large size)
Policy/help documentsPersistent popup window
Error messagesDialog (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

RuleImplementation
Focus trapshadcn Sheet and Dialog handle this automatically via Radix UI
Escape closesshadcn handles: onKeyDown for Escape
Return focusFocus returns to trigger element on close
Screen reader announcementSheetTitle and DialogTitle are required — they become the aria-label
Background scroll lockApplied 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 Sheet with side="left" w-[280px] p-0 flex flex-col
  • Sheet closes on route change (useEffect watching pathname)
  • SheetTitle is always present (screen reader requirement)
  • Sheet sizes follow the size table (w-[280px] mobile nav, sm:max-w-xl detail)
  • Detail sheets have flex flex-col + flex-1 overflow-y-auto body + fixed footer
  • No custom z-index overrides — rely on shadcn's z-50
  • No iframe for policy pages — use openPersistentPopup utility
  • 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 AlertCircle icon + AlertTitle + AlertDescription