Responsive Dialog
A dialog component that automatically switches between a centered dialog on desktop and a bottom drawer on mobile.
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogTrigger,
} from "@/components/ui/responsive-dialog";
export function ResponsiveDialogDemo() {
return (
<ResponsiveDialog>
<ResponsiveDialogTrigger asChild>
<Button variant="outline">Edit Profile</Button>
</ResponsiveDialogTrigger>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Edit profile</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
Make changes to your profile here. Click save when you're done.
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<Input
id="name"
defaultValue="Pedro Duarte"
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="username" className="text-right">
Username
</Label>
<Input
id="username"
defaultValue="@peduarte"
className="col-span-3"
/>
</div>
</div>
<ResponsiveDialogFooter>
<Button type="submit">Save changes</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}Installation
CLI
npx shadcn@latest add @diceui/responsive-dialogManual
Install the following dependencies:
npm install vaulCopy and paste the following hooks into your hooks directory.
import * as React from "react";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
function useAsRef<T>(props: T) {
const ref = React.useRef<T>(props);
useIsomorphicLayoutEffect(() => {
ref.current = props;
});
return ref;
}
export { useAsRef };import * as React from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
export { useIsomorphicLayoutEffect };import * as React from "react";
function useLazyRef<T>(fn: () => T) {
const ref = React.useRef<T | null>(null);
if (ref.current === null) {
ref.current = fn();
}
return ref as React.RefObject<T>;
}
export { useLazyRef };import * as React from "react";
const MOBILE_BREAKPOINT = 768;
function useIsMobile(breakpoint = MOBILE_BREAKPOINT) {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < breakpoint);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < breakpoint);
return () => mql.removeEventListener("change", onChange);
}, [breakpoint]);
return !!isMobile;
}
export { useIsMobile };Copy and paste the dialog and drawer components into your project.
Copy and paste the following code into your project.
"use client";
import * as React from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerOverlay,
DrawerPortal,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
import { useAsRef } from "@/components/hooks/use-as-ref";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
import { useLazyRef } from "@/components/hooks/use-lazy-ref";
import { useIsMobile } from "@/components/hooks/use-mobile";
const ROOT_NAME = "ResponsiveDialog";
interface StoreState {
open: boolean;
isMobile: boolean;
}
interface Store {
subscribe: (callback: () => void) => () => void;
getState: () => StoreState;
setState: <K extends keyof StoreState>(key: K, value: StoreState[K]) => void;
notify: () => void;
}
const StoreContext = React.createContext<Store | null>(null);
function useStore<T>(
selector: (state: StoreState) => T,
ogStore?: Store | null,
): T {
const contextStore = React.useContext(StoreContext);
const store = ogStore ?? contextStore;
if (!store) {
throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``);
}
const getSnapshot = React.useCallback(
() => selector(store.getState()),
[store, selector],
);
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
interface ResponsiveDialogProps extends React.ComponentProps<typeof Dialog> {
breakpoint?: number;
}
function ResponsiveDialog({
breakpoint = 768,
open: openProp,
defaultOpen = false,
onOpenChange: onOpenChangeProp,
...props
}: ResponsiveDialogProps) {
const isMobile = useIsMobile(breakpoint);
const listenersRef = useLazyRef(() => new Set<() => void>());
const stateRef = useLazyRef<StoreState>(() => ({
open: openProp ?? defaultOpen,
isMobile,
}));
const onOpenChangeRef = useAsRef(onOpenChangeProp);
const store = React.useMemo<Store>(() => {
return {
subscribe: (cb) => {
listenersRef.current.add(cb);
return () => listenersRef.current.delete(cb);
},
getState: () => stateRef.current,
setState: (key, value) => {
if (Object.is(stateRef.current[key], value)) return;
if (key === "open" && typeof value === "boolean") {
stateRef.current.open = value;
onOpenChangeRef.current?.(value);
} else {
stateRef.current[key] = value;
}
store.notify();
},
notify: () => {
for (const cb of listenersRef.current) {
cb();
}
},
};
}, [listenersRef, stateRef, onOpenChangeRef]);
const open = useStore((state) => state.open, store);
useIsomorphicLayoutEffect(() => {
if (openProp !== undefined) {
store.setState("open", openProp);
}
}, [openProp]);
useIsomorphicLayoutEffect(() => {
store.setState("isMobile", isMobile);
}, [isMobile]);
const onOpenChange = React.useCallback(
(value: boolean) => {
store.setState("open", value);
},
[store],
);
if (isMobile) {
return (
<StoreContext.Provider value={store}>
<Drawer open={open} onOpenChange={onOpenChange} {...props} />
</StoreContext.Provider>
);
}
return (
<StoreContext.Provider value={store}>
<Dialog open={open} onOpenChange={onOpenChange} {...props} />
</StoreContext.Provider>
);
}
function ResponsiveDialogTrigger({
...props
}: React.ComponentProps<typeof DialogTrigger>) {
const isMobile = useStore((state) => state.isMobile);
if (isMobile) {
return <DrawerTrigger data-variant="drawer" {...props} />;
}
return <DialogTrigger data-variant="dialog" {...props} />;
}
function ResponsiveDialogClose({
...props
}: React.ComponentProps<typeof DialogClose>) {
const isMobile = useStore((state) => state.isMobile);
if (isMobile) {
return <DrawerClose data-variant="drawer" {...props} />;
}
return <DialogClose data-variant="dialog" {...props} />;
}
function ResponsiveDialogPortal({
...props
}: React.ComponentProps<typeof DialogPortal>) {
const isMobile = useStore((state) => state.isMobile);
if (isMobile) {
return <DrawerPortal data-variant="drawer" {...props} />;
}
return <DialogPortal data-variant="dialog" {...props} />;
}
function ResponsiveDialogOverlay({
...props
}: React.ComponentProps<typeof DialogOverlay>) {
const isMobile = useStore((state) => state.isMobile);
if (isMobile) {
return <DrawerOverlay data-variant="drawer" {...props} />;
}
return <DialogOverlay data-variant="dialog" {...props} />;
}
function ResponsiveDialogContent({
className,
...props
}: React.ComponentProps<typeof DialogContent>) {
const isMobile = useStore((state) => state.isMobile);
if (isMobile) {
return (
<DrawerContent
data-variant="drawer"
className={cn("px-4 pb-4", className)}
{...props}
/>
);
}
return (
<DialogContent data-variant="dialog" className={className} {...props} />
);
}
function ResponsiveDialogHeader({
...props
}: React.ComponentProps<typeof DialogHeader>) {
const isMobile = useStore((state) => state.isMobile);
if (isMobile) {
return <DrawerHeader data-variant="drawer" {...props} />;
}
return <DialogHeader data-variant="dialog" {...props} />;
}
function ResponsiveDialogFooter({
...props
}: React.ComponentProps<typeof DialogFooter>) {
const isMobile = useStore((state) => state.isMobile);
if (isMobile) {
return <DrawerFooter data-variant="drawer" {...props} />;
}
return <DialogFooter data-variant="dialog" {...props} />;
}
function ResponsiveDialogTitle({
...props
}: React.ComponentProps<typeof DialogTitle>) {
const isMobile = useStore((state) => state.isMobile);
if (isMobile) {
return <DrawerTitle data-variant="drawer" {...props} />;
}
return <DialogTitle data-variant="dialog" {...props} />;
}
function ResponsiveDialogDescription({
...props
}: React.ComponentProps<typeof DialogDescription>) {
const isMobile = useStore((state) => state.isMobile);
if (isMobile) {
return <DrawerDescription data-variant="drawer" {...props} />;
}
return <DialogDescription data-variant="dialog" {...props} />;
}
export {
ResponsiveDialog,
ResponsiveDialogClose,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogOverlay,
ResponsiveDialogPortal,
ResponsiveDialogTitle,
ResponsiveDialogTrigger,
};Update the import paths to match your project setup.
Layout
Import the parts, and compose them together.
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogTrigger,
} from "@/components/ui/responsive-dialog";
<ResponsiveDialog>
<ResponsiveDialogTrigger />
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle />
<ResponsiveDialogDescription />
</ResponsiveDialogHeader>
<ResponsiveDialogFooter />
</ResponsiveDialogContent>
</ResponsiveDialog>Examples
Confirmation Dialog
Use the responsive dialog to confirm destructive actions like deleting items.
"use client";
import { Loader2, TrashIcon } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
ResponsiveDialog,
ResponsiveDialogClose,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogTrigger,
} from "@/components/ui/responsive-dialog";
export function ResponsiveDialogConfirmDemo() {
const [isDeleting, setIsDeleting] = React.useState(false);
const onDelete = React.useCallback(() => {
setIsDeleting(true);
// Simulate deletion
setTimeout(() => {
setIsDeleting(false);
}, 1000);
}, []);
return (
<ResponsiveDialog>
<ResponsiveDialogTrigger asChild>
<Button variant="destructive">
<TrashIcon />
Delete Project
</Button>
</ResponsiveDialogTrigger>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Delete project?</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
This will permanently delete "My Awesome Project" and all
of its data. This action cannot be undone.
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<ResponsiveDialogFooter>
<ResponsiveDialogClose asChild>
<Button variant="outline">Cancel</Button>
</ResponsiveDialogClose>
<Button
variant="destructive"
onClick={onDelete}
disabled={isDeleting}
>
{isDeleting && <Loader2 className="size-4 animate-spin" />}
Delete
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}Variant Styling
Each component exposes a data-variant attribute that can be used to apply different styles based on whether the dialog or drawer is rendered.
<ResponsiveDialogContent className="data-[variant=drawer]:pb-8 data-[variant=dialog]:max-w-md">
{/* content */}
</ResponsiveDialogContent>
<ResponsiveDialogFooter className="data-[variant=drawer]:flex-col data-[variant=dialog]:flex-row">
{/* buttons */}
</ResponsiveDialogFooter>API Reference
ResponsiveDialog
The root component that manages the dialog/drawer state.
Prop
Type
ResponsiveDialogTrigger
The button that opens the dialog/drawer.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-variant] | "dialog" | "drawer" |
ResponsiveDialogContent
The content container for the dialog/drawer.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-variant] | "dialog" | "drawer" |
ResponsiveDialogHeader
The header section of the dialog/drawer.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-variant] | "dialog" | "drawer" |
ResponsiveDialogFooter
The footer section of the dialog/drawer.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-variant] | "dialog" | "drawer" |
ResponsiveDialogTitle
The title of the dialog/drawer.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-variant] | "dialog" | "drawer" |
ResponsiveDialogDescription
The description of the dialog/drawer.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-variant] | "dialog" | "drawer" |
ResponsiveDialogClose
The close button for the dialog/drawer.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-variant] | "dialog" | "drawer" |
ResponsiveDialogOverlay
The overlay behind the dialog/drawer.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-variant] | "dialog" | "drawer" |
ResponsiveDialogPortal
The portal container for the dialog/drawer.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-variant] | "dialog" | "drawer" |
Accessibility
Adheres to the Dialog WAI-ARIA design pattern.
Keyboard Interactions
| Key | Description |
|---|---|
| Space | Opens/closes the dialog when focus is on the trigger. |
| Enter | Opens/closes the dialog when focus is on the trigger. |
| Tab | Moves focus to the next focusable element. |
| Shift + Tab | Moves focus to the previous focusable element. |
| Escape | Closes the dialog/drawer and moves focus to the trigger. |