Dice UI
Components

Responsive Dialog

A dialog component that automatically switches between a centered dialog on desktop and a bottom drawer on mobile.

API
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&apos;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-dialog

Manual

Install the following dependencies:

npm install vaul

Copy 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 &quot;My Awesome Project&quot; 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 AttributeValue
[data-variant]"dialog" | "drawer"

ResponsiveDialogContent

The content container for the dialog/drawer.

Prop

Type

Data AttributeValue
[data-variant]"dialog" | "drawer"

ResponsiveDialogHeader

The header section of the dialog/drawer.

Prop

Type

Data AttributeValue
[data-variant]"dialog" | "drawer"

ResponsiveDialogFooter

The footer section of the dialog/drawer.

Prop

Type

Data AttributeValue
[data-variant]"dialog" | "drawer"

ResponsiveDialogTitle

The title of the dialog/drawer.

Prop

Type

Data AttributeValue
[data-variant]"dialog" | "drawer"

ResponsiveDialogDescription

The description of the dialog/drawer.

Prop

Type

Data AttributeValue
[data-variant]"dialog" | "drawer"

ResponsiveDialogClose

The close button for the dialog/drawer.

Prop

Type

Data AttributeValue
[data-variant]"dialog" | "drawer"

ResponsiveDialogOverlay

The overlay behind the dialog/drawer.

Prop

Type

Data AttributeValue
[data-variant]"dialog" | "drawer"

ResponsiveDialogPortal

The portal container for the dialog/drawer.

Prop

Type

Data AttributeValue
[data-variant]"dialog" | "drawer"

Accessibility

Adheres to the Dialog WAI-ARIA design pattern.

Keyboard Interactions

KeyDescription
SpaceOpens/closes the dialog when focus is on the trigger.
EnterOpens/closes the dialog when focus is on the trigger.
TabMoves focus to the next focusable element.
Shift + TabMoves focus to the previous focusable element.
EscapeCloses the dialog/drawer and moves focus to the trigger.

On this page