Component Composition Patterns
Your Dialog component has 14 props: showTitle, titleText, showDescription, descriptionText, showCloseButton, closeButtonPosition... and someone just asked for a custom footer. You could add three more props, or you could learn the pattern that makes this problem disappear.
Composition scales better than prop soup. Compound patterns reduce coupling and improve reuse.
Why compound components?
- Flexibility: Rearrange, add, or remove subcomponents without changing the component API
- Readability: Declarative structure mirrors the visual hierarchy (no prop soup)
- TypeScript simplicity: Each subcomponent has its own focused props interface
- Server Component children: Pass server-rendered content through client wrappers without serialization limits
- Customization: Replace individual subcomponents without forking the entire component
Outcome
A reusable compound component using @repo/ui primitives.
Fast Track
- Define
Dialog.Root,Dialog.Trigger,Dialog.Content,Dialog.Title,Dialog.Description. - Keep state in a tiny client wrapper; content can be server-rendered.
- Ensure accessibility basics (focus, escape close).
Hands-On Exercise 2.2
Build a compound component pattern for flexible, reusable UI.
Requirements:
- Implement the compound API shape (Dialog.Trigger, Dialog.Content, Dialog.Title, Dialog.Description).
- Use it in both apps to gate an action.
- Verify accessibility (a11y) affordances (focus trap, escape close).
- Demonstrate composability with server-rendered content.
Implementation hints:
- Compound component pattern: Like HTML elements (
<table>,<thead>,<tbody>), use subcomponents instead of prop configuration. - Flexibility benefits: Arrange subcomponents as needed, add/remove/rearrange parts easily, avoid prop soup like
showTitle,titleText,showDescription,descriptionText. - State management: Keep state in small client wrapper (
'use client'on Dialog.Root), content can be server-rendered children. - Reusable patterns: Create accessible UI patterns that work across the codebase.
- Lean on
@radix-ui/react-slotpatterns (Radix's Slot component lets you pass props to a child element, enabling polymorphic components). - Keep public API stable; hide internals.
Try It
- Trigger dialog; verify focus trap and close behavior.
Commit & Deploy
git add -A
git commit -m "feat(core): add dialog compound component pattern"
git push -u origin feat/core-dialog-patternDone-When
- Navigate to
/demo/dialogin web app: click "Delete Account" button and dialog opens - Navigate to
/demo/dialogin blog app: click "Subscribe to Newsletter" button and dialog opens - With dialog open, press Tab key repeatedly: focus cycles only through dialog elements, not page behind
- With dialog open, press Escape key: dialog closes
- With dialog open, click dark overlay outside dialog: dialog closes
- Open DevTools Accessibility panel with dialog open: verify
role="dialog"andaria-labelledbyattributes present (ARIA attributes help screen readers understand UI relationships) - In web app
/demo/dialog, click "View Terms": server-generated date appears in dialog content
Solution
Solution
Prerequisites
Install the Radix Dialog primitive in the @repo/ui package:
cd packages/ui
pnpm add @radix-ui/react-dialogStep 1: Create the Dialog Compound Component
Build the compound component in the shared UI package so both apps can use it.
'use client'
import {
Close,
Content,
Description,
Overlay,
Portal,
Root,
Title,
Trigger,
} from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
import { cn } from '@repo/ui/lib/utils'
// Re-export Root and Trigger directly (no customization needed)
const DialogRoot = Root
const DialogTrigger = Trigger
const DialogClose = Close
/**
* Dialog.Overlay - Covers the screen behind the dialog
*/
function DialogOverlay({
className,
...props
}: ComponentPropsWithoutRef<typeof Overlay>) {
return (
<Overlay
className={cn(
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed inset-0 z-50 bg-black/80',
className
)}
{...props}
/>
)
}
/**
* Dialog.Content - The dialog panel itself
* Children can be Server Components (passed through)
*/
function DialogContent({
children,
className,
...props
}: ComponentPropsWithoutRef<typeof Content>) {
return (
<Portal>
<DialogOverlay />
<Content
className={cn(
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]',
'gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200',
className
)}
{...props}
>
{children}
<Close
className={cn(
'absolute top-4 right-4 rounded-sm opacity-70',
'ring-offset-background transition-opacity hover:opacity-100',
'focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none',
'disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground'
)}
>
<X className="size-4" />
<span className="sr-only">Close</span>
</Close>
</Content>
</Portal>
)
}
/**
* Dialog.Header - Container for title and description
*/
function DialogHeader({
className,
...props
}: ComponentPropsWithoutRef<'div'>) {
return (
<div
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
)
}
/**
* Dialog.Footer - Container for action buttons
*/
function DialogFooter({
className,
...props
}: ComponentPropsWithoutRef<'div'>) {
return (
<div
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className
)}
{...props}
/>
)
}
/**
* Dialog.Title - Accessible dialog title
*/
function DialogTitle({
className,
...props
}: ComponentPropsWithoutRef<typeof Title>) {
return (
<Title
className={cn('font-semibold text-lg leading-none tracking-tight', className)}
{...props}
/>
)
}
/**
* Dialog.Description - Accessible dialog description
*/
function DialogDescription({
className,
...props
}: ComponentPropsWithoutRef<typeof Description>) {
return (
<Description
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
// Export individual components (required for Server Component compatibility)
export {
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTitle,
DialogTrigger,
}Step 2: Export from the UI Package
Add the dialog export to the package.json exports:
{
"name": "@repo/ui",
"exports": {
"./globals.css": "./src/styles/globals.css",
"./lib/*": "./src/lib/*.ts",
"./components/*": "./src/components/*.tsx",
"./hooks/*": "./src/hooks/*.ts"
}
}Step 3: Use the Dialog in Web App
Create a demo page that uses the compound component pattern.
// Server Component - renders server content inside the dialog
import { Button } from '@repo/ui/components/button'
import {
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTitle,
DialogTrigger,
} from '@repo/ui/components/dialog'
// Simulated server data
async function getTermsContent(): Promise<string> {
// This would fetch from a CMS or database in a real app
return `By using this service, you agree to our terms. These terms were
last updated on ${new Date().toLocaleDateString()}. Server-rendered content
can be passed as children to client components.`
}
export default async function DialogDemoPage() {
// Fetch content on the server
const termsContent = await getTermsContent()
return (
<main className="flex flex-col gap-8 p-8">
<div>
<h1 className="font-bold text-3xl">Dialog Compound Component</h1>
<p className="mt-2 text-muted-foreground">
Compound API with server-rendered content inside client dialog.
</p>
</div>
{/* Confirmation dialog example */}
<section className="rounded-lg border bg-muted/50 p-6">
<h2 className="font-semibold text-lg">Confirmation Dialog</h2>
<p className="mb-4 text-sm text-muted-foreground">
Gates a destructive action with user confirmation.
</p>
<DialogRoot>
<DialogTrigger asChild>
<Button type="button" variant="destructive">
Delete Account
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. Your account and all associated
data will be permanently deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button type="button" variant="destructive">
Delete
</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
</section>
{/* Server content inside dialog */}
<section className="rounded-lg border bg-muted/50 p-6">
<h2 className="font-semibold text-lg">Server Content in Dialog</h2>
<p className="mb-4 text-sm text-muted-foreground">
Terms content fetched on server, rendered inside client dialog.
</p>
<DialogRoot>
<DialogTrigger asChild>
<Button type="button" variant="outline">
View Terms
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Terms of Service</DialogTitle>
<DialogDescription>
Please read these terms carefully.
</DialogDescription>
</DialogHeader>
{/* Server-rendered content passed through */}
<div className="rounded-md bg-muted p-4 text-sm">
{termsContent}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button">I Understand</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</DialogRoot>
</section>
{/* A11y notes */}
<section className="rounded-lg border bg-muted/50 p-6">
<h2 className="font-semibold text-lg">Accessibility Features</h2>
<ul className="mt-2 list-inside list-disc space-y-1 text-sm text-muted-foreground">
<li>Focus trap: Tab cycles through dialog elements only</li>
<li>Escape key: Closes the dialog</li>
<li>Click outside: Closes the dialog</li>
<li>ARIA: Title and Description linked via aria-labelledby/describedby</li>
<li>Screen reader: Announces dialog open/close</li>
</ul>
</section>
</main>
)
}Step 4: Use the Dialog in Blog App
Demonstrate the same component works across apps.
// Server Component - blog app also uses the shared Dialog
import { Button } from '@repo/ui/components/button'
import {
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTitle,
DialogTrigger,
} from '@repo/ui/components/dialog'
export default function BlogDialogDemoPage() {
return (
<main className="flex flex-col gap-8 p-8">
<div>
<h1 className="font-bold text-3xl">Blog: Dialog Demo</h1>
<p className="mt-2 text-muted-foreground">
Same compound component from @repo/ui works in blog app.
</p>
</div>
{/* Newsletter signup dialog */}
<section className="rounded-lg border bg-muted/50 p-6">
<h2 className="font-semibold text-lg">Newsletter Signup</h2>
<p className="mb-4 text-sm text-muted-foreground">
Example: gate newsletter subscription behind a dialog.
</p>
<DialogRoot>
<DialogTrigger asChild>
<Button type="button">Subscribe to Newsletter</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Join Our Newsletter</DialogTitle>
<DialogDescription>
Get the latest articles delivered to your inbox.
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-4">
<input
type="email"
placeholder="your@email.com"
className="rounded-md border bg-background px-3 py-2 text-sm"
/>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button type="submit">Subscribe</Button>
</DialogFooter>
</form>
</DialogContent>
</DialogRoot>
</section>
</main>
)
}File Structure
packages/ui/src/components/
└── dialog.tsx # Compound component definition
apps/web/src/app/demo/dialog/
└── page.tsx # Web app usage
apps/blog/src/app/demo/dialog/
└── page.tsx # Blog app usage
Verification
-
Test focus trap:
- Open dialog, press Tab repeatedly
- Focus should cycle through dialog elements only, not background
-
Test escape close:
- Open dialog, press Escape
- Dialog should close
-
Test click outside:
- Open dialog, click the dark overlay
- Dialog should close
-
Test screen reader:
- Open browser DevTools → Accessibility panel
- Verify dialog has proper ARIA attributes
-
Test server content:
- Visit
/demo/dialog, click "View Terms" - Content should show server-generated date
- Visit
Compound Pattern Benefits
| Feature | Props Pattern | Compound Pattern |
|---|---|---|
| Flexibility | ❌ Fixed structure | ✅ Any arrangement |
| Readability | ❌ Prop soup | ✅ Clear structure |
| TypeScript | ❌ Complex unions | ✅ Simple per-component |
| Server children | ❌ Serialize through props | ✅ Pass as children |
| Customization | ❌ Add more props | ✅ Replace subcomponent |
Troubleshooting
Component renders as empty or "Cannot read properties of undefined"
Error: Element type is invalid: expected a string (for built-in components)
or a class/function (for composite components) but got: undefined.
Or you see:
Cannot read properties of undefined (reading 'Root')
Cause: You're using namespace exports (export const Dialog = { Root, Trigger, Content }) which break during SSR/prerendering in Next.js 16. The namespace object exports as undefined in Server Components.
Fix: Use named exports instead of namespace exports:
// ❌ BREAKS in Server Components
export const Dialog = {
Root: DialogRoot,
Trigger: DialogTrigger,
Content: DialogContent,
}
// ✅ WORKS
export {
DialogRoot,
DialogTrigger,
DialogContent,
}Then import individually:
// ❌ BREAKS
import { Dialog } from '@repo/ui/components/dialog'
<Dialog.Root>...</Dialog.Root>
// ✅ WORKS
import { DialogRoot, DialogTrigger, DialogContent } from '@repo/ui/components/dialog'
<DialogRoot>...</DialogRoot>The compound pattern still works, just with different import syntax.
References
Was this helpful?