Dialog Media
Proposal for extending Dialog with a media component
Dialog Media
A dedicated slot for full-width media content within dialogs. DialogMedia renders edge-to-edge at the top of DialogContent, automatically adjusting padding so other dialog elements maintain proper spacing.
It works seamlessly with shadcn's existing composition pattern – add DialogMedia as a child of DialogContent and the layout adapts.
Installation
You can install the proposed dialog upgrade through a registry to test
npx shadcn@latest add https://dialog-media.georgedrury.co.uk/r/dialogUsage
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogMedia
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogMedia>{/*Insert media here */}</DialogMedia>
<DialogHeader>
<DialogTitle>Are you absolutely sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete your account
and remove your data from our servers.
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>Use cases
Decorative Illustrations, icons, or visual context that reinforces the dialog's message. Onboarding flows, empty states, success confirmations.
Functional Content that serves a purpose: image croppers, video players, product galleries, document previews.
Interactive Media that responds to user input: carousels, multi-step wizards, before/after comparisons.
Examples
Carousel
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogMedia,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
} from "@/components/ui/carousel"
export default function CarouselDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Carousel Media</Button>
</DialogTrigger>
<DialogContent showCloseButton={false} className="sm:max-w-md">
<DialogMedia className="overflow-hidden p-16">
<Carousel>
<CarouselContent>
{Array.from({ length: 5 }).map((_, index) => (
<CarouselItem key={index}>
<Card>
<CardContent className="flex aspect-square items-center justify-center p-6">
<span className="text-4xl font-semibold">{index + 1}</span>
</CardContent>
</Card>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</DialogMedia>
<DialogHeader>
<DialogTitle>What's new</DialogTitle>
<DialogDescription>
See what we've been working on this month.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Dismiss</Button>
</DialogClose>
<Button>Learn more</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}Multi-step
"use client"
import { Button } from "@/components/ui/button"
import {
CheckIcon,
SparklesIcon,
RocketIcon,
PartyPopperIcon,
} from "lucide-react"
import React from "react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogMedia,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
const steps = [
{
title: "Welcome to Acme",
description: "We're excited to have you here. Let's get you set up.",
icon: SparklesIcon,
},
{
title: "Discover features",
description: "Explore powerful tools designed to boost your productivity.",
icon: RocketIcon,
},
{
title: "You're all set!",
description: "Your workspace is ready. Start creating amazing things today.",
icon: PartyPopperIcon,
},
]
export default function OnboardingDialog() {
const [currentStep, setCurrentStep] = React.useState(0)
const step = steps[currentStep]
const Icon = step.icon
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Multi-step</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogMedia className="border-b aspect-3/2 flex flex-col items-center justify-center gap-6">
<div className="size-24 rounded-xl bg-background border flex items-center justify-center shadow-xl">
<Icon className="size-12 stroke-1 stroke-foreground" />
</div>
<div className="flex items-center gap-2">
{steps.map((_, index) => (
<button
key={index}
onClick={() => setCurrentStep(index)}
className={cn(
"size-1.5 rounded-full transition-all",
index === currentStep
? "bg-foreground w-6"
: index < currentStep
? "bg-foreground/60"
: "bg-foreground/20"
)}
/>
))}
</div>
</DialogMedia>
<DialogHeader>
<DialogTitle>{step.title}</DialogTitle>
<DialogDescription>{step.description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setCurrentStep(Math.max(0, currentStep - 1))}
disabled={currentStep === 0}
>
Back
</Button>
<Button onClick={() => setCurrentStep(Math.min(steps.length - 1, currentStep + 1))}>
{currentStep === steps.length - 1 ? (
<>
<CheckIcon className="size-4" />
Get started
</>
) : (
"Next"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}Image Crop
"use client"
import { Button } from "@/components/ui/button"
import { Slider } from "@/components/ui/slider"
import { ZoomInIcon, ZoomOutIcon } from "lucide-react"
import Image from "next/image"
import React from "react"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogMedia,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
export default function ImageCropDialog() {
const [zoom, setZoom] = React.useState([1])
const [position, setPosition] = React.useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = React.useState(false)
const dragStart = React.useRef({ x: 0, y: 0 })
const positionStart = React.useRef({ x: 0, y: 0 })
const handleMouseDown = (e: React.MouseEvent) => {
setIsDragging(true)
dragStart.current = { x: e.clientX, y: e.clientY }
positionStart.current = { x: position.x, y: position.y }
}
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging) return
const dx = e.clientX - dragStart.current.x
const dy = e.clientY - dragStart.current.y
const maxOffset = ((zoom[0] - 1) / zoom[0]) * 50
setPosition({
x: Math.max(-maxOffset, Math.min(maxOffset, positionStart.current.x + dx / 3)),
y: Math.max(-maxOffset, Math.min(maxOffset, positionStart.current.y + dy / 3)),
})
}
const handleMouseUp = () => setIsDragging(false)
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Image Crop</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogMedia
className="aspect-square overflow-hidden p-0 bg-black relative select-none"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ cursor: isDragging ? "grabbing" : "grab" }}
>
<div className="absolute inset-0 flex items-center justify-center z-10 pointer-events-none">
<div className="size-[90%] border-2 border-white/80 rounded-full" />
</div>
<div
className="absolute inset-0 bg-black/30 z-5 pointer-events-none"
style={{
maskImage: "radial-gradient(circle at center, transparent 65%, black 65%)",
WebkitMaskImage: "radial-gradient(circle at center, transparent 64%, black 64%)",
}}
/>
<Image
fill
src="/your-image.jpg"
alt="Image to crop"
className="object-cover pointer-events-none"
draggable={false}
style={{ transform: `scale(${zoom[0]}) translate(${position.x}%, ${position.y}%)` }}
/>
</DialogMedia>
<DialogHeader>
<DialogTitle>Crop image</DialogTitle>
<DialogDescription>Drag to reposition. Use the slider to zoom.</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-4">
<ZoomOutIcon className="size-4 text-muted-foreground" />
<Slider value={zoom} onValueChange={setZoom} min={1} max={3} step={0.1} className="flex-1" />
<ZoomInIcon className="size-4 text-muted-foreground" />
</div>
<DialogFooter className="flex gap-2">
<DialogClose asChild>
<Button variant="outline" className="flex-1">Cancel</Button>
</DialogClose>
<Button className="flex-1">Apply</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}Video Player
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogMedia,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
export default function VideoDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Video Player</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg border-none">
<DialogMedia className="aspect-video overflow-hidden p-0 bg-black">
<video
className="size-full object-cover"
controls
poster="/video-poster.jpg"
>
<source src="/demo-video.mp4" type="video/mp4" />
Your browser does not support the video tag.
</video>
</DialogMedia>
<DialogHeader>
<DialogTitle>Getting started tutorial</DialogTitle>
<DialogDescription>
Learn how to set up your workspace in under 5 minutes.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Watch later</Button>
</DialogClose>
<Button>Continue</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}Confirmation
"use client"
import { Button } from "@/components/ui/button"
import { AlertTriangleIcon } from "lucide-react"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogMedia,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
export default function ConfirmationDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Destructive</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogMedia className="aspect-2/1 flex items-center justify-center bg-destructive/5">
<div className="size-16 rounded-full bg-destructive/5 flex items-center justify-center">
<AlertTriangleIcon className="size-8 text-destructive" />
</div>
</DialogMedia>
<DialogHeader>
<DialogTitle>Delete project?</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete your
project and remove all associated data.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button variant="destructive">Delete project</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}How it works
DialogMedia uses the data-slot pattern and CSS :has() selector for automatic layout adaptation:
DialogMediais a simple div withdata-slot="dialog-media"DialogContentdetects its presence via:has([data-slot='dialog-media'])- When detected,
DialogContentremoves its padding and clips overflow - Sibling elements receive horizontal padding to maintain alignment
This means:
- Opt-in – add
DialogMediaonly when needed - No JavaScript – layout handled purely via CSS
Changes to existing dialog components
Minimal changes to existing dialog component to cater for DialogMedia:
DialogContent
Adding group/dialog and :has() selectors
className={cn(
// existing classes...
"group/dialog",
// When DialogMedia is present: remove padding, clip overflow
"has-data-[slot='dialog-media']:p-0 has-data-[slot='dialog-media']:overflow-hidden",
// Restore horizontal padding to non-media siblings
"has-data-[slot='dialog-media']:[&>*:not([data-slot='dialog-media']):not([data-slot='dialog-close'])]:px-6",
className
)}DialogHeader
Adding top padding when media is present:
className={cn(
// existing classes...
"group-has-data-[slot=dialog-media]/dialog:pt-2",
className
)}DialogFooter
Adding bottom padding when media is present:
className={cn(
// existing classes...
"group-has-data-[slot=dialog-media]/dialog:pb-6",
className
)}DialogMedia
Adding the new component:
function DialogMedia({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-media"
className={cn(
"bg-accent p-6 min-h-32 relative [&>img]:object-cover",
className
)}
{...props}
/>
)
}Full code
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogMedia({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-media"
className={cn(
"bg-muted p-6 min-h-32 relative [&>img]:object-cover",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"group/dialog border bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 p-6 rounded-xl shadow-lg duration-200 outline-none sm:max-w-lg",
"has-data-[slot='dialog-media']:p-0 has-data-[slot='dialog-media']:overflow-hidden",
"has-data-[slot='dialog-media']:[&>*:not([data-slot='dialog-media']):not([data-slot='dialog-close'])]:px-6",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn(
"flex flex-col gap-2 text-center sm:text-left group-has-data-[slot=dialog-media]/dialog:pt-2",
className
)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
"group-has-data-[slot=dialog-media]/dialog:px-4 group-has-data-[slot=dialog-media]/dialog:pb-6",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogMedia,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}