# Modal

A modal window that opens as a centered dialog on desktop and a bottom drawer on mobile.

Modal is an open-source pattern from suiss/ui for React. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/patterns/modal

**React**

**Example**

```tsx
import {
  Modal,
  ModalBody,
  ModalClose,
  ModalContent,
  ModalDescription,
  ModalFooter,
  ModalHeader,
  ModalTitle,
  ModalTrigger,
} from "@/components/patterns/modal"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export default function ModalDemo() {
  return (
    <div className="flex min-h-svh items-center justify-center">
      <Modal>
        <ModalTrigger render={<Button variant="outline" />}>
          Edit profile
        </ModalTrigger>
        <ModalContent>
          <ModalHeader>
            <ModalTitle>Edit profile</ModalTitle>
            <ModalDescription>
              Update your name and username. Click save when you are done.
            </ModalDescription>
          </ModalHeader>
          <ModalBody className="grid gap-4">
            <div className="grid gap-2">
              <Label htmlFor="modal-name">Name</Label>
              <Input id="modal-name" defaultValue="Ada Lovelace" />
            </div>
            <div className="grid gap-2">
              <Label htmlFor="modal-username">Username</Label>
              <Input id="modal-username" defaultValue="@ada" />
            </div>
          </ModalBody>
          <ModalFooter>
            <ModalClose render={<Button variant="secondary" />}>
              Cancel
            </ModalClose>
            <Button>Save changes</Button>
          </ModalFooter>
        </ModalContent>
      </Modal>
    </div>
  )
}
```

`Modal` presents focused content that blocks the rest of the page, such as a form or a share sheet. On desktop it renders a `Dialog`. Below the `48rem` breakpoint it renders a bottom `Drawer` with a swipe handle, and each part switches to its drawer counterpart: the header, title, description and close map to the drawer's parts, `ModalBody` gains padding, and `ModalFooter` stacks its buttons in reverse order so the primary action sits on top.

Use it for tasks that need the user's full attention before returning to the page.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/modal
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add patterns modal
```

### Manual

1. Add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper).

2. Add the pieces it builds on: [Dialog](https://ui.suiss.app/docs/components/dialog), [Drawer](https://ui.suiss.app/docs/components/drawer), `use-mobile`.

3. Copy the source into `components/patterns/modal.tsx`:

```tsx title="components/patterns/modal.tsx"
"use client"

import * as React from "react"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"

type ModalContextProps = {
  isMobile: boolean
}

const ModalContext = React.createContext<ModalContextProps | null>(null)

function useModal() {
  const context = React.useContext(ModalContext)

  if (!context) {
    throw new Error("useModal must be used within a <Modal />")
  }

  return context
}

function Modal({
  open,
  defaultOpen,
  onOpenChange,
  floating,
  children,
}: Pick<
  React.ComponentProps<typeof Dialog>,
  "open" | "defaultOpen" | "children"
> & {
  onOpenChange?: (open: boolean) => void
  floating?: boolean
}) {
  const isMobile = useIsMobile()
  const rootProps = {
    open,
    defaultOpen,
    onOpenChange: (next: boolean) => onOpenChange?.(next),
    children,
  }

  return (
    <ModalContext.Provider value={{ isMobile }}>
      {isMobile ? (
        <Drawer
          data-slot="modal"
          floating={floating}
          showSwipeHandle
          {...rootProps}
        />
      ) : (
        <Dialog data-slot="modal" {...rootProps} />
      )}
    </ModalContext.Provider>
  )
}

function ModalTrigger(
  props: Omit<
    React.ComponentProps<typeof DialogTrigger>,
    "handle" | "className"
  > & {
    className?: string
  }
) {
  const { isMobile } = useModal()
  const Trigger = isMobile ? DrawerTrigger : DialogTrigger

  return <Trigger data-slot="modal-trigger" {...props} />
}

function ModalContent({
  showCloseButton = true,
  ...props
}: Omit<
  React.ComponentProps<typeof DialogContent>,
  "className" | "style" | "render"
> & {
  className?: string
  style?: React.CSSProperties
}) {
  const { isMobile } = useModal()

  if (isMobile) {
    return (
      <DrawerContent
        data-slot="modal-content"
        showCloseButton={showCloseButton}
        {...props}
      />
    )
  }

  return (
    <DialogContent
      data-slot="modal-content"
      showCloseButton={showCloseButton}
      {...props}
    />
  )
}

function ModalHeader(props: React.ComponentProps<"div">) {
  const { isMobile } = useModal()
  const Header = isMobile ? DrawerHeader : DialogHeader

  return <Header data-slot="modal-header" {...props} />
}

function ModalBody({ className, ...props }: React.ComponentProps<"div">) {
  const { isMobile } = useModal()

  return (
    <div
      data-slot="modal-body"
      className={cn(isMobile && "p-4", className)}
      {...props}
    />
  )
}

function ModalFooter({ className, ...props }: React.ComponentProps<"div">) {
  const { isMobile } = useModal()

  if (isMobile) {
    return (
      <DrawerFooter
        data-slot="modal-footer"
        className={cn("flex-col-reverse", className)}
        {...props}
      />
    )
  }

  return (
    <DialogFooter data-slot="modal-footer" className={className} {...props} />
  )
}

function ModalTitle(props: React.ComponentProps<typeof DialogTitle>) {
  const { isMobile } = useModal()
  const Title = isMobile ? DrawerTitle : DialogTitle

  return <Title data-slot="modal-title" {...props} />
}

function ModalDescription(
  props: React.ComponentProps<typeof DialogDescription>
) {
  const { isMobile } = useModal()
  const Description = isMobile ? DrawerDescription : DialogDescription

  return <Description data-slot="modal-description" {...props} />
}

function ModalClose(props: React.ComponentProps<typeof DialogClose>) {
  const { isMobile } = useModal()
  const Close = isMobile ? DrawerClose : DialogClose

  return <Close data-slot="modal-close" {...props} />
}

export {
  Modal,
  ModalBody,
  ModalClose,
  ModalContent,
  ModalDescription,
  ModalFooter,
  ModalHeader,
  ModalTitle,
  ModalTrigger,
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { Modal, ModalTrigger, ModalContent, ModalHeader, ModalBody, ModalFooter, ModalTitle, ModalDescription, ModalClose } from "@/components/patterns/modal"
```

```tsx
<div className="flex min-h-svh items-center justify-center">
  <Modal>
    <ModalTrigger render={<Button variant="outline" />}>
      Edit profile
    </ModalTrigger>
    <ModalContent>
      <ModalHeader>
        <ModalTitle>Edit profile</ModalTitle>
        <ModalDescription>
          Update your name and username. Click save when you are done.
        </ModalDescription>
      </ModalHeader>
      <ModalBody className="grid gap-4">
        <div className="grid gap-2">
          <Label htmlFor="modal-name">Name</Label>
          <Input id="modal-name" defaultValue="Ada Lovelace" />
        </div>
        <div className="grid gap-2">
          <Label htmlFor="modal-username">Username</Label>
          <Input id="modal-username" defaultValue="@ada" />
        </div>
      </ModalBody>
      <ModalFooter>
        <ModalClose render={<Button variant="secondary" />}>
          Cancel
        </ModalClose>
        <Button>Save changes</Button>
      </ModalFooter>
    </ModalContent>
  </Modal>
</div>
```

`ModalContent` shows a close button by default; set `showCloseButton` to `false` to hide it, or pass `closeLabel` to show text instead of the icon.

The modal can be controlled with `open` and `onOpenChange` or uncontrolled with `defaultOpen`. Pass `render` to `ModalTrigger` or `ModalClose` to use your own `Button`.

## Examples

### With close button

Relies on the default corner close button as the only way to dismiss besides `Escape`.

**Example**

```tsx
import {
  Modal,
  ModalBody,
  ModalContent,
  ModalDescription,
  ModalFooter,
  ModalHeader,
  ModalTitle,
  ModalTrigger,
} from "@/components/patterns/modal"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

export default function ModalWithCloseButton() {
  return (
    <div className="flex min-h-svh items-center justify-center">
      <Modal>
        <ModalTrigger render={<Button variant="outline" />}>
          Share project
        </ModalTrigger>
        <ModalContent>
          <ModalHeader>
            <ModalTitle>Share project</ModalTitle>
            <ModalDescription>
              Anyone with the link can view this project.
            </ModalDescription>
          </ModalHeader>
          <ModalBody>
            <Input
              readOnly
              aria-label="Project link"
              defaultValue="https://example.com/p/1042"
            />
          </ModalBody>
          <ModalFooter>
            <Button>Copy link</Button>
          </ModalFooter>
        </ModalContent>
      </Modal>
    </div>
  )
}
```

### With close label

Replaces the close icon with a text button using `closeLabel`.

**Example**

```tsx
import {
  Modal,
  ModalBody,
  ModalContent,
  ModalDescription,
  ModalFooter,
  ModalHeader,
  ModalTitle,
  ModalTrigger,
} from "@/components/patterns/modal"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

export default function ModalWithCloseLabel() {
  return (
    <div className="flex min-h-svh items-center justify-center">
      <Modal>
        <ModalTrigger render={<Button variant="outline" />}>
          Share project
        </ModalTrigger>
        <ModalContent closeLabel="Close">
          <ModalHeader>
            <ModalTitle>Share project</ModalTitle>
            <ModalDescription>
              Anyone with the link can view this project.
            </ModalDescription>
          </ModalHeader>
          <ModalBody>
            <Input
              readOnly
              aria-label="Project link"
              defaultValue="https://example.com/p/1042"
            />
          </ModalBody>
          <ModalFooter>
            <Button>Copy link</Button>
          </ModalFooter>
        </ModalContent>
      </Modal>
    </div>
  )
}
```

### Floating

Insets the mobile drawer from the screen edges with `floating`.

**Example**

```tsx
import {
  Modal,
  ModalBody,
  ModalClose,
  ModalContent,
  ModalDescription,
  ModalFooter,
  ModalHeader,
  ModalTitle,
  ModalTrigger,
} from "@/components/patterns/modal"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export default function ModalFloating() {
  return (
    <div className="flex min-h-svh items-center justify-center">
      <Modal floating>
        <ModalTrigger render={<Button variant="outline" />}>
          Edit profile
        </ModalTrigger>
        <ModalContent>
          <ModalHeader>
            <ModalTitle>Edit profile</ModalTitle>
            <ModalDescription>
              Update your name and username. Click save when you are done.
            </ModalDescription>
          </ModalHeader>
          <ModalBody className="grid gap-4">
            <div className="grid gap-2">
              <Label htmlFor="modal-name">Name</Label>
              <Input id="modal-name" defaultValue="Ada Lovelace" />
            </div>
            <div className="grid gap-2">
              <Label htmlFor="modal-username">Username</Label>
              <Input id="modal-username" defaultValue="@ada" />
            </div>
          </ModalBody>
          <ModalFooter>
            <ModalClose render={<Button variant="secondary" />}>
              Cancel
            </ModalClose>
            <Button>Save changes</Button>
          </ModalFooter>
        </ModalContent>
      </Modal>
    </div>
  )
}
```

## API Reference

- `Modal`
- `ModalTrigger`
- `ModalContent`
  - `ModalHeader`
    - `ModalTitle`
    - `ModalDescription`
  - `ModalBody`
  - `ModalFooter`
    - `ModalClose`

### Modal

The root that renders a `Dialog` on desktop and a `Drawer` on mobile.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onOpenChange` | `(open: boolean) => void` | - | Called when the modal opens or closes. |
| `floating` | `boolean` | - | Insets the mobile drawer from the screen edges with fully rounded corners. |

Also accepts the props of `React.ComponentProps<typeof Dialog>`.

### ModalTrigger

The button that opens the modal.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `className` | `string` | - | Classes applied to the trigger. |

Also accepts the props of `React.ComponentProps<typeof DialogTrigger>`.

### ModalContent

The popup window, with a close button by default.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `className` | `string` | - | Classes applied to the dialog or drawer popup. |
| `style` | `React.CSSProperties` | - | Inline styles applied to the dialog or drawer popup. |

Also accepts the props of `React.ComponentProps<typeof DialogContent>`.

### ModalHeader

Groups the title and description.

Also accepts the props of `React.ComponentProps<"div">`.

### ModalBody

The main content area, padded on mobile.

Also accepts the props of `React.ComponentProps<"div">`.

### ModalFooter

Holds the modal's actions; stacked with the primary action on top on mobile.

Also accepts the props of `React.ComponentProps<"div">`.

### ModalTitle

The modal's title, used as its accessible name.

Also accepts the props of `React.ComponentProps<typeof DialogTitle>`.

### ModalDescription

Supporting text, used as the modal's accessible description.

Also accepts the props of `React.ComponentProps<typeof DialogDescription>`.

### ModalClose

A button that closes the modal.

Also accepts the props of `React.ComponentProps<typeof DialogClose>`.

## Accessibility

- The popup has the `dialog` role, named by `ModalTitle` and described by `ModalDescription`.
- `Escape` closes the modal and returns focus to the trigger.
- The default close button is labelled Close.

## Notes

Parts carry `data-slot` values from `modal` through `modal-close`. `ModalContent` accepts `className` and `style` as plain values only, because the same props are forwarded to either the dialog or the drawer popup.

