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.

Title
This is a description

Installation

You can install the proposed dialog upgrade through a registry to test

npx shadcn@latest add https://dialog-media.georgedrury.co.uk/r/dialog

Usage

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

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:

  1. DialogMedia is a simple div with data-slot="dialog-media"
  2. DialogContent detects its presence via :has([data-slot='dialog-media'])
  3. When detected, DialogContent removes its padding and clips overflow
  4. Sibling elements receive horizontal padding to maintain alignment

This means:

  • Opt-in – add DialogMedia only 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,
}

Feedback

https://github.com/shadcn-ui/ui/discussions/9138