# Dialog

A modal window that appears above the page to focus people on a task or message.

Dialog is an open-source component from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/components/dialog

**React**

**Example**

```tsx
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export default function DialogDemo() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline" />}>
        Edit profile
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Edit profile</DialogTitle>
          <DialogDescription>
            Update your name and username. Click save when you are done.
          </DialogDescription>
        </DialogHeader>
        <div className="grid gap-4">
          <div className="grid gap-2">
            <Label htmlFor="name">Name</Label>
            <Input id="name" defaultValue="Ada Lovelace" />
          </div>
          <div className="grid gap-2">
            <Label htmlFor="username">Username</Label>
            <Input id="username" defaultValue="@ada" />
          </div>
        </div>
        <DialogFooter>
          <DialogClose render={<Button variant="secondary" />}>
            Cancel
          </DialogClose>
          <Button>Save changes</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}
```

Dialog wraps the dialog primitive of the chosen base. The `Dialog` root manages the open state, and `DialogTrigger` opens it. `DialogContent` renders the window in a portal over a dimmed `DialogOverlay`, with a close button in the top corner by default. Inside it, `DialogHeader` holds the `DialogTitle` and `DialogDescription`, and `DialogFooter` holds the actions. `DialogClose` closes the dialog from any element.

Use a dialog for a short, focused task such as editing a few fields. To confirm a destructive or important action that needs an explicit response, consider an alert dialog. For larger content that slides in from an edge, use a sheet or drawer.

## Installation

### CLI

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

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 dialog
```

### Manual

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

```bash title="Terminal"
npm install @base-ui/react @phosphor-icons/react
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), `use-scrollbar-gap`.

3. Copy the source into `components/ui/dialog.tsx`:

```tsx title="components/ui/dialog.tsx"
"use client"

import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { XIcon } from "@phosphor-icons/react"
import type * as React from "react"
import { Button } from "@/components/ui/button"
import { useScrollbarGap } from "@/hooks/use-scrollbar-gap"
import { cn } from "@/lib/utils"

function Dialog({ ...props }: DialogPrimitive.Root.Props) {
  return <DialogPrimitive.Root data-slot="dialog" {...props} />
}

function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
  return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}

function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
  return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}

function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
  return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}

function DialogOverlay({
  className,
  ...props
}: DialogPrimitive.Backdrop.Props) {
  useScrollbarGap()

  return (
    <DialogPrimitive.Backdrop
      data-slot="dialog-overlay"
      className={cn(
        "fixed inset-0 isolate z-50 bg-black/50 duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
        className
      )}
      {...props}
    />
  )
}

function DialogContent({
  className,
  children,
  showCloseButton = true,
  closeLabel,
  ...props
}: DialogPrimitive.Popup.Props & {
  showCloseButton?: boolean
  closeLabel?: string
}) {
  return (
    <DialogPortal>
      <DialogOverlay />
      <DialogPrimitive.Popup
        data-slot="dialog-content"
        className={cn(
          "has-[>[data-slot=dialog-close]]:*:first:pe-8 has-[>[data-slot=dialog-close][data-label]]:*:first:pe-18 fixed top-1/2 inset-s-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 rtl:translate-x-1/2 -translate-y-1/2 gap-6 rounded-2xl bg-popover p-6 text-base text-popover-foreground shadow-xl ring-1 ring-foreground/5 duration-100 outline-none sm:max-w-md dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
          className
        )}
        {...props}
      >
        {children}
        {showCloseButton && (
          <DialogPrimitive.Close
            data-slot="dialog-close"
            data-label={closeLabel ? "" : undefined}
            render={
              <Button
                variant={closeLabel ? "plain" : "ghost"}
                className={cn(
                  "absolute top-4.75 inset-e-4",
                  closeLabel && "text-base",
                  !closeLabel &&
                    "bg-secondary hover:bg-[color-mix(in_oklab,var(--secondary),var(--foreground)_5%)]"
                )}
                size={closeLabel ? "sm" : "icon-sm"}
              />
            }
          >
            {closeLabel ?? (
              <>
                <XIcon />
                <span className="sr-only">Close</span>
              </>
            )}
          </DialogPrimitive.Close>
        )}
      </DialogPrimitive.Popup>
    </DialogPortal>
  )
}

function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="dialog-header"
      className={cn("flex flex-col gap-1.5", className)}
      {...props}
    />
  )
}

function DialogFooter({
  className,
  showCloseButton = false,
  children,
  ...props
}: React.ComponentProps<"div"> & {
  showCloseButton?: boolean
}) {
  return (
    <div
      data-slot="dialog-footer"
      className={cn(
        "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
        className
      )}
      {...props}
    >
      {children}
      {showCloseButton && (
        <DialogPrimitive.Close render={<Button variant="secondary" />}>
          Close
        </DialogPrimitive.Close>
      )}
    </div>
  )
}

function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
  return (
    <DialogPrimitive.Title
      data-slot="dialog-title"
      className={cn("text-base font-semibold", className)}
      {...props}
    />
  )
}

function DialogDescription({
  className,
  ...props
}: DialogPrimitive.Description.Props) {
  return (
    <DialogPrimitive.Description
      data-slot="dialog-description"
      className={cn(
        "text-sm text-muted-foreground *:[a]:underline *:[a]:hover:text-foreground",
        className
      )}
      {...props}
    />
  )
}

export {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogOverlay,
  DialogPortal,
  DialogTitle,
  DialogTrigger,
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { Dialog, DialogTrigger, DialogPortal, DialogClose, DialogOverlay, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription } from "@/components/ui/dialog"
```

```tsx
<Dialog>
  <DialogTrigger render={<Button variant="outline" />}>
    Edit profile
  </DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Edit profile</DialogTitle>
      <DialogDescription>
        Update your name and username. Click save when you are done.
      </DialogDescription>
    </DialogHeader>
    <div className="grid gap-4">
      <div className="grid gap-2">
        <Label htmlFor="name">Name</Label>
        <Input id="name" defaultValue="Ada Lovelace" />
      </div>
      <div className="grid gap-2">
        <Label htmlFor="username">Username</Label>
        <Input id="username" defaultValue="@ada" />
      </div>
    </div>
    <DialogFooter>
      <DialogClose render={<Button variant="secondary" />}>
        Cancel
      </DialogClose>
      <Button>Save changes</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>
```

The dialog is uncontrolled by default; set `defaultOpen` to start open, or control it with `open` and `onOpenChange`. Render the trigger as another component, such as a `Button`, with the `render` prop. Use `DialogClose` with `render` for a Cancel button.

Set `showCloseButton={false}` on `DialogContent` to remove the corner close button, and set `closeLabel` to replace its icon with a text label. Set `showCloseButton` on `DialogFooter` to add a secondary Close button to the actions. The footer stacks its actions in reverse order on small screens and aligns them to the end on wider screens.

## Examples

### Without close button

A dialog with no corner close button that adds a Close action to the footer instead.

**Example**

```tsx
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"

export default function DialogWithoutCloseButton() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline" />}>
        Show terms
      </DialogTrigger>
      <DialogContent showCloseButton={false}>
        <DialogHeader>
          <DialogTitle>Terms updated</DialogTitle>
          <DialogDescription>
            We have updated our terms of service. Please review the changes
            before continuing.
          </DialogDescription>
        </DialogHeader>
        <DialogFooter showCloseButton>
          <Button>Accept</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}
```

### Destructive

A dialog that confirms deleting a project, with a destructive action and a Cancel button.

**Example**

```tsx
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"

export default function DialogDestructive() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="destructive" />}>
        Delete project
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Delete this project?</DialogTitle>
          <DialogDescription>
            This permanently removes the project and all of its deployments.
            This action cannot be undone.
          </DialogDescription>
        </DialogHeader>
        <DialogFooter>
          <DialogClose render={<Button variant="secondary" />}>
            Cancel
          </DialogClose>
          <Button variant="destructive">Delete</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}
```

## API Reference

- `Dialog`
- `DialogTrigger`
- `DialogContent`
  - `DialogHeader`
    - `DialogTitle`
    - `DialogDescription`
  - `DialogFooter`
    - `DialogClose`

### Dialog

The root that manages the dialog's open state.

Also accepts the props of the primitive part it wraps.

### DialogTrigger

The button that opens the dialog.

Also accepts the props of the primitive part it wraps.

### DialogPortal

Renders its children into a portal outside the current DOM hierarchy.

Also accepts the props of the primitive part it wraps.

### DialogClose

A button that closes the dialog.

Also accepts the props of the primitive part it wraps.

### DialogOverlay

The dimmed layer behind the dialog.

Also accepts the props of the primitive part it wraps.

### DialogContent

The dialog window, rendered in a portal with an overlay and an optional close button.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `showCloseButton` | `boolean` | `true` | Shows the close button in the top corner. |
| `closeLabel` | `string` | - | Replaces the close button's icon with this text. |

Also accepts the props of the primitive part it wraps.

### DialogHeader

Groups the title and description.

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

### DialogFooter

Holds the dialog's actions.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `showCloseButton` | `boolean` | `false` | Adds a secondary Close button after the footer's actions. |

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

### DialogTitle

The dialog's title, which also names it for assistive technology.

Also accepts the props of the primitive part it wraps.

### DialogDescription

Supporting text that describes the dialog for assistive technology.

Also accepts the props of the primitive part it wraps.

## Accessibility

- The window has the `dialog` role. `DialogTitle` provides its accessible name and `DialogDescription` its accessible description.
- Opening the dialog moves focus inside it. Escape closes it and returns focus to the trigger.
- The icon-only close button has a visually hidden `Close` label.
- Always include a `DialogTitle`; hide it visually with `sr-only` if the design has no visible title.

## Notes

- Styling hooks: `data-slot` values `dialog-overlay`, `dialog-content`, `dialog-close`, `dialog-header`, `dialog-footer`, `dialog-title` and `dialog-description`.
- `data-open` and `data-closed` drive the fade and zoom animation.
- The window is `sm:max-w-md` by default; override it with `className` on `DialogContent`.
- When a close button is shown, the first child of the content gets extra end padding so it does not run under the button.
- Uses the `popover`, `secondary` and `muted-foreground` colors, with a `bg-black/50` backdrop.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/components-dialog--open-by-default.light.webp)

```tsx
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Text } from "@/components/ui/text"

function EditProfileContent() {
  return (
    <DialogContent>
      <DialogHeader>
        <DialogTitle>Edit profile</DialogTitle>
        <DialogDescription>
          Make changes to your profile here. Tap save when you&apos;re done.
        </DialogDescription>
      </DialogHeader>
      <View className="gap-4">
        <View className="gap-2">
          <Label nativeID="dialog-name">Name</Label>
          <Input
            accessibilityLabelledBy="dialog-name"
            defaultValue="Pedro Duarte"
          />
        </View>
        <View className="gap-2">
          <Label nativeID="dialog-username">Username</Label>
          <Input
            accessibilityLabelledBy="dialog-username"
            defaultValue="@peduarte"
            autoCapitalize="none"
          />
        </View>
      </View>
      <DialogFooter>
        <DialogClose render={<Button variant="secondary" />}>
          <Text>Cancel</Text>
        </DialogClose>
        <Button>
          <Text>Save changes</Text>
        </Button>
      </DialogFooter>
    </DialogContent>
  )
}

export function DialogOpenByDefaultExample() {
  return (
    <View className="items-start">
      <Dialog defaultOpen>
        <DialogTrigger render={<Button variant="outline" />}>
          <Text>Edit profile</Text>
        </DialogTrigger>
        <EditProfileContent />
      </Dialog>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct DialogOpenByDefaultExample: View {
  var body: some View {
    DialogEditProfile(open: true)
  }
}

struct DialogEditProfile: View {
  @State private var open: Bool

  init(open: Bool = false) {
    _open = State(initialValue: open)
  }

  var body: some View {
    Button("Edit profile") { open = true }
      .buttonStyle(.variant(.outline))
      .dialog(isPresented: $open) {
        DialogEditProfileContent()
      }
  }
}

struct DialogEditProfileContent: View {
  @State private var name = "Pedro Duarte"
  @State private var username = "@peduarte"

  var body: some View {
    DialogContent {
      DialogHeader {
        DialogTitle("Edit profile")
        DialogDescription("Make changes to your profile here. Tap save when you're done.")
      }
      VStack(alignment: .leading, spacing: 16) {
        VStack(alignment: .leading, spacing: 8) {
          FieldLabel("Name")
            .accessibilityHidden(true)
          TextField("Name", text: $name)
            .textFieldStyle(.input)
        }
        VStack(alignment: .leading, spacing: 8) {
          FieldLabel("Username")
            .accessibilityHidden(true)
          TextField("Username", text: $username)
            .textFieldStyle(.input)
            .textInputAutocapitalization(.never)
        }
      }
      DialogFooter {
        DialogClose {
          Text("Cancel").frame(maxWidth: .infinity)
        }
        .buttonStyle(.variant(.secondary))
        Button {} label: {
          Text("Save changes").frame(maxWidth: .infinity)
        }
        .buttonStyle(.variant())
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Dialog
import com.esuiss.ui.components.DialogDescription
import com.esuiss.ui.components.DialogFooter
import com.esuiss.ui.components.DialogHeader
import com.esuiss.ui.components.DialogTitle
import com.esuiss.ui.components.Input
import com.esuiss.ui.components.Label

@Composable
fun DialogOpenByDefaultExample() {
    DialogDefault(defaultOpen = true)
}

@Composable
fun DialogDefault(defaultOpen: Boolean) {
    var open by rememberSaveable { mutableStateOf(defaultOpen) }
    var name by rememberSaveable { mutableStateOf("Pedro Duarte") }
    var username by rememberSaveable { mutableStateOf("@peduarte") }
    Column {
        Button("Edit profile", onClick = { open = true }, variant = ButtonVariant.Outline)
        Dialog(open = open, onOpenChange = { open = it }) {
            DialogHeader {
                DialogTitle("Edit profile")
                DialogDescription("Make changes to your profile here. Tap save when you're done.")
            }
            Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
                Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                    Label("Name")
                    Input(name, { name = it }, Modifier.semantics { contentDescription = "Name" })
                }
                Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                    Label("Username")
                    Input(
                        username,
                        { username = it },
                        Modifier.semantics { contentDescription = "Username" },
                        keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None),
                    )
                }
            }
            DialogFooter {
                Button("Cancel", onClick = { open = false }, modifier = Modifier.fillMaxWidth(), variant = ButtonVariant.Secondary)
                Button("Save changes", onClick = {}, modifier = Modifier.fillMaxWidth())
            }
        }
    }
}
```

`Dialog` opens a centered card over a dimmed scrim. `DialogContent` renders through a portal (inside a full-window overlay on iOS), scales and fades in, and shows a round close button in the top-right corner by default. Tapping the scrim closes the dialog.

Use it for short forms and information that the user can dismiss freely. When the user must make an explicit choice, use Alert Dialog instead.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/dialog
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

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

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add dialog --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add dialog --platform kotlin
```

The CLI also adds what it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-screens react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/dialog.tsx`, `components/primitives/use-render.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/dialog.tsx`:

```tsx title="uniwind/components/ui/dialog.tsx"
import { cn } from "cn"
import { XIcon } from "phosphor-react-native"
import * as React from "react"
import { Platform, View, type ViewProps } from "react-native"
import {
  FadeIn,
  FadeOut,
  Keyframe,
  ReduceMotion,
} from "react-native-reanimated"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as DialogPrimitive from "@/components/primitives/dialog"
import { resolveClassName } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

const Dialog = DialogPrimitive.Root

const DialogTrigger = DialogPrimitive.Trigger

const DialogPortal = DialogPrimitive.Portal

const DialogClose = DialogPrimitive.Close

const FullWindowOverlay =
  Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment

const POPUP_ENTERING = new Keyframe({
  0: { opacity: 0, transform: [{ scale: 0.95 }] },
  100: { opacity: 1, transform: [{ scale: 1 }] },
})
  .duration(150)
  .reduceMotion(ReduceMotion.System)

const POPUP_EXITING = new Keyframe({
  0: { opacity: 1, transform: [{ scale: 1 }] },
  100: { opacity: 0, transform: [{ scale: 0.95 }] },
})
  .duration(100)
  .reduceMotion(ReduceMotion.System)

const DialogCloseContext = React.createContext({
  hasCloseButton: false,
  hasCloseLabel: false,
})

function DialogOverlay({
  className,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Backdrop>) {
  return (
    <DialogPrimitive.Backdrop
      render={
        <AnimatedView
          as="Pressable"
          entering={FadeIn.duration(150).reduceMotion(ReduceMotion.System)}
          exiting={FadeOut.duration(100).reduceMotion(ReduceMotion.System)}
        />
      }
      className={(state) =>
        cn(
          "absolute top-0 right-0 bottom-0 left-0 z-50 bg-black/50",
          resolveClassName(className, state)
        )
      }
      {...props}
    />
  )
}

function DialogCloseButton({ closeLabel }: { closeLabel?: string }) {
  return (
    <DialogPrimitive.Close
      render={
        <Button
          variant={closeLabel ? "plain" : "ghost"}
          size={closeLabel ? "sm" : "icon-sm"}
          className={cn(
            "absolute top-[19px] right-4",
            !closeLabel && "bg-secondary active:opacity-80"
          )}
        />
      }
      hitSlop={8}
      accessibilityLabel={closeLabel ? undefined : "Close"}
    >
      {closeLabel ? (
        <Text className="text-base">{closeLabel}</Text>
      ) : (
        <Icon as={XIcon} className="size-4 text-foreground" />
      )}
    </DialogPrimitive.Close>
  )
}

function DialogContent({
  className,
  portalHost,
  showCloseButton = true,
  closeLabel,
  children,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Popup> & {
  portalHost?: string
  showCloseButton?: boolean
  closeLabel?: string
}) {
  const closeContext = React.useMemo(
    () => ({
      hasCloseButton: showCloseButton,
      hasCloseLabel: showCloseButton && closeLabel !== undefined,
    }),
    [showCloseButton, closeLabel]
  )
  return (
    <DialogPortal hostName={portalHost}>
      <FullWindowOverlay>
        <DialogOverlay />
        <View
          className="absolute top-0 right-0 bottom-0 left-0 z-50 items-center justify-center p-4"
          style={{ pointerEvents: "box-none" }}
        >
          <AnimatedView
            entering={POPUP_ENTERING}
            exiting={POPUP_EXITING}
            className="w-full max-w-md"
            style={{ pointerEvents: "box-none" }}
          >
            <DialogPrimitive.Popup
              className={(state) =>
                cn(
                  "z-50 w-full flex-col gap-6 rounded-2xl border border-foreground/5 bg-popover p-6 shadow-xl dark:border-foreground/10",
                  resolveClassName(className, state)
                )
              }
              {...props}
            >
              <DialogCloseContext.Provider value={closeContext}>
                {children}
              </DialogCloseContext.Provider>
              {showCloseButton ? (
                <DialogCloseButton closeLabel={closeLabel} />
              ) : null}
            </DialogPrimitive.Popup>
          </AnimatedView>
        </View>
      </FullWindowOverlay>
    </DialogPortal>
  )
}

function DialogHeader({ className, ...props }: ViewProps) {
  const { hasCloseButton, hasCloseLabel } = React.useContext(DialogCloseContext)
  return (
    <View
      className={cn(
        "flex-col gap-1.5",
        hasCloseButton && "pr-8",
        hasCloseLabel && "pr-18",
        className
      )}
      {...props}
    />
  )
}

function DialogFooter({
  className,
  showCloseButton = false,
  children,
  ...props
}: ViewProps & { showCloseButton?: boolean }) {
  return (
    <View
      className={cn(
        "flex-col-reverse gap-2 sm:flex-row sm:justify-end",
        className
      )}
      {...props}
    >
      {children}
      {showCloseButton ? (
        <DialogPrimitive.Close render={<Button variant="secondary" />}>
          <Text>Close</Text>
        </DialogPrimitive.Close>
      ) : null}
    </View>
  )
}

function DialogTitle({
  className,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
  return (
    <DialogPrimitive.Title
      className={(state) =>
        cn(
          "text-base font-semibold text-foreground",
          resolveClassName(className, state)
        )
      }
      {...props}
    />
  )
}

function DialogDescription({
  className,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
  return (
    <DialogPrimitive.Description
      className={(state) =>
        cn("text-sm text-muted-foreground", resolveClassName(className, state))
      }
      {...props}
    />
  )
}

export {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogOverlay,
  DialogPortal,
  DialogTitle,
  DialogTrigger,
}
```

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-screens react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/dialog.tsx`, `components/primitives/use-render.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/dialog.tsx`:

```tsx title="nativewind/components/ui/dialog.tsx"
import { cn } from "cn"
import { XIcon } from "phosphor-react-native"
import * as React from "react"
import { Platform, View, type ViewProps } from "react-native"
import {
  FadeIn,
  FadeOut,
  Keyframe,
  ReduceMotion,
} from "react-native-reanimated"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as DialogPrimitive from "@/components/primitives/dialog"
import { resolveClassName } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

const Dialog = DialogPrimitive.Root

const DialogTrigger = DialogPrimitive.Trigger

const DialogPortal = DialogPrimitive.Portal

const DialogClose = DialogPrimitive.Close

const FullWindowOverlay =
  Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment

const POPUP_ENTERING = new Keyframe({
  0: { opacity: 0, transform: [{ scale: 0.95 }] },
  100: { opacity: 1, transform: [{ scale: 1 }] },
})
  .duration(150)
  .reduceMotion(ReduceMotion.System)

const POPUP_EXITING = new Keyframe({
  0: { opacity: 1, transform: [{ scale: 1 }] },
  100: { opacity: 0, transform: [{ scale: 0.95 }] },
})
  .duration(100)
  .reduceMotion(ReduceMotion.System)

const DialogCloseContext = React.createContext({
  hasCloseButton: false,
  hasCloseLabel: false,
})

function DialogOverlay({
  className,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Backdrop>) {
  return (
    <DialogPrimitive.Backdrop
      render={
        <AnimatedView
          as="Pressable"
          entering={FadeIn.duration(150).reduceMotion(ReduceMotion.System)}
          exiting={FadeOut.duration(100).reduceMotion(ReduceMotion.System)}
        />
      }
      className={(state) =>
        cn(
          "absolute top-0 right-0 bottom-0 left-0 z-50 bg-black/50",
          resolveClassName(className, state)
        )
      }
      {...props}
    />
  )
}

function DialogCloseButton({ closeLabel }: { closeLabel?: string }) {
  return (
    <DialogPrimitive.Close
      render={
        <Button
          variant={closeLabel ? "plain" : "ghost"}
          size={closeLabel ? "sm" : "icon-sm"}
          className={cn(
            "absolute top-[19px] right-4",
            !closeLabel && "bg-secondary active:opacity-80"
          )}
        />
      }
      hitSlop={8}
      accessibilityLabel={closeLabel ? undefined : "Close"}
    >
      {closeLabel ? (
        <Text className="text-base">{closeLabel}</Text>
      ) : (
        <Icon as={XIcon} className="size-4 text-foreground" />
      )}
    </DialogPrimitive.Close>
  )
}

function DialogContent({
  className,
  portalHost,
  showCloseButton = true,
  closeLabel,
  children,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Popup> & {
  portalHost?: string
  showCloseButton?: boolean
  closeLabel?: string
}) {
  const closeContext = React.useMemo(
    () => ({
      hasCloseButton: showCloseButton,
      hasCloseLabel: showCloseButton && closeLabel !== undefined,
    }),
    [showCloseButton, closeLabel]
  )
  return (
    <DialogPortal hostName={portalHost}>
      <FullWindowOverlay>
        <DialogOverlay />
        <View
          className="absolute top-0 right-0 bottom-0 left-0 z-50 items-center justify-center p-4"
          style={{ pointerEvents: "box-none" }}
        >
          <AnimatedView
            entering={POPUP_ENTERING}
            exiting={POPUP_EXITING}
            className="w-full max-w-md"
            style={{ pointerEvents: "box-none" }}
          >
            <DialogPrimitive.Popup
              className={(state) =>
                cn(
                  "z-50 w-full flex-col gap-6 rounded-2xl border border-foreground-5 bg-popover p-6 shadow-xl dark:border-foreground-10",
                  resolveClassName(className, state)
                )
              }
              {...props}
            >
              <DialogCloseContext.Provider value={closeContext}>
                {children}
              </DialogCloseContext.Provider>
              {showCloseButton ? (
                <DialogCloseButton closeLabel={closeLabel} />
              ) : null}
            </DialogPrimitive.Popup>
          </AnimatedView>
        </View>
      </FullWindowOverlay>
    </DialogPortal>
  )
}

function DialogHeader({ className, ...props }: ViewProps) {
  const { hasCloseButton, hasCloseLabel } = React.useContext(DialogCloseContext)
  return (
    <View
      className={cn(
        "flex-col gap-1.5",
        hasCloseButton && "pr-8",
        hasCloseLabel && "pr-[4.5rem]",
        className
      )}
      {...props}
    />
  )
}

function DialogFooter({
  className,
  showCloseButton = false,
  children,
  ...props
}: ViewProps & { showCloseButton?: boolean }) {
  return (
    <View
      className={cn(
        "flex-col-reverse gap-2 sm:flex-row sm:justify-end",
        className
      )}
      {...props}
    >
      {children}
      {showCloseButton ? (
        <DialogPrimitive.Close render={<Button variant="secondary" />}>
          <Text>Close</Text>
        </DialogPrimitive.Close>
      ) : null}
    </View>
  )
}

function DialogTitle({
  className,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
  return (
    <DialogPrimitive.Title
      className={(state) =>
        cn(
          "text-base font-semibold text-foreground",
          resolveClassName(className, state)
        )
      }
      {...props}
    />
  )
}

function DialogDescription({
  className,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
  return (
    <DialogPrimitive.Description
      className={(state) =>
        cn("text-sm text-muted-foreground", resolveClassName(className, state))
      }
      {...props}
    />
  )
}

export {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogOverlay,
  DialogPortal,
  DialogTitle,
  DialogTrigger,
}
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-screens react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/dialog.tsx`, `components/primitives/use-render.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/dialog.tsx`:

```tsx title="stylesheet/components/ui/dialog.tsx"
import { XIcon } from "phosphor-react-native"
import * as React from "react"
import {
  Platform,
  StyleSheet,
  useWindowDimensions,
  View,
  type ViewProps,
} from "react-native"
import {
  FadeIn,
  FadeOut,
  Keyframe,
  ReduceMotion,
} from "react-native-reanimated"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as DialogPrimitive from "@/components/primitives/dialog"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"
import { fontSize, radius, shadow, useTheme } from "@/components/ui/theme"

const Dialog = DialogPrimitive.Root

const DialogTrigger = DialogPrimitive.Trigger

const DialogPortal = DialogPrimitive.Portal

const DialogClose = DialogPrimitive.Close

const FullWindowOverlay =
  Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment

const POPUP_ENTERING = new Keyframe({
  0: { opacity: 0, transform: [{ scale: 0.95 }] },
  100: { opacity: 1, transform: [{ scale: 1 }] },
})
  .duration(150)
  .reduceMotion(ReduceMotion.System)

const POPUP_EXITING = new Keyframe({
  0: { opacity: 1, transform: [{ scale: 1 }] },
  100: { opacity: 0, transform: [{ scale: 0.95 }] },
})
  .duration(100)
  .reduceMotion(ReduceMotion.System)

const DialogCloseContext = React.createContext({
  hasCloseButton: false,
  hasCloseLabel: false,
})

const styles = StyleSheet.create({
  backdrop: { ...StyleSheet.absoluteFill, zIndex: 50 },
  container: {
    ...StyleSheet.absoluteFill,
    zIndex: 50,
    alignItems: "center",
    justifyContent: "center",
    padding: 16,
    pointerEvents: "box-none",
  },
  containerInner: {
    width: "100%",
    maxWidth: 448,
    pointerEvents: "box-none",
  },
  content: {
    zIndex: 50,
    width: "100%",
    flexDirection: "column",
    gap: 24,
    borderRadius: radius["2xl"],
    borderWidth: 1,
    padding: 24,
    ...shadow.xl,
  },
  close: { position: "absolute", top: 19, right: 16 },
  closeLabel: { ...fontSize.base },
  header: { flexDirection: "column", gap: 6 },
  headerClose: { paddingRight: 32 },
  headerCloseLabel: { paddingRight: 72 },
  footer: { flexDirection: "column-reverse", gap: 8 },
  footerSm: { flexDirection: "row", justifyContent: "flex-end" },
  title: { ...fontSize.base, fontWeight: "600" },
  description: { ...fontSize.sm },
})

function withAlpha(hex: string, alpha: number) {
  const value = Number.parseInt(hex.slice(1), 16)
  return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})`
}

function DialogOverlay({
  style,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Backdrop>) {
  return (
    <DialogPrimitive.Backdrop
      render={
        <AnimatedView
          as="Pressable"
          entering={FadeIn.duration(150).reduceMotion(ReduceMotion.System)}
          exiting={FadeOut.duration(100).reduceMotion(ReduceMotion.System)}
        />
      }
      style={(state) => [
        styles.backdrop,
        { backgroundColor: "rgba(0, 0, 0, 0.5)" },
        typeof style === "function" ? style(state) : style,
      ]}
      {...props}
    />
  )
}

function DialogCloseButton({ closeLabel }: { closeLabel?: string }) {
  const { colors } = useTheme()
  return (
    <DialogPrimitive.Close
      render={
        <Button
          variant={closeLabel ? "plain" : "ghost"}
          size={closeLabel ? "sm" : "icon-sm"}
          style={[
            styles.close,
            !closeLabel && { backgroundColor: colors.secondary },
          ]}
        />
      }
      hitSlop={8}
      accessibilityLabel={closeLabel ? undefined : "Close"}
    >
      {closeLabel ? (
        <Text style={styles.closeLabel}>{closeLabel}</Text>
      ) : (
        <Icon as={XIcon} size={16} color={colors.foreground} />
      )}
    </DialogPrimitive.Close>
  )
}

function DialogContent({
  style,
  portalHost,
  showCloseButton = true,
  closeLabel,
  children,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Popup> & {
  portalHost?: string
  showCloseButton?: boolean
  closeLabel?: string
}) {
  const { colors, isDark } = useTheme()
  const closeContext = React.useMemo(
    () => ({
      hasCloseButton: showCloseButton,
      hasCloseLabel: showCloseButton && closeLabel !== undefined,
    }),
    [showCloseButton, closeLabel]
  )
  return (
    <DialogPortal hostName={portalHost}>
      <FullWindowOverlay>
        <DialogOverlay />
        <View style={styles.container}>
          <AnimatedView
            entering={POPUP_ENTERING}
            exiting={POPUP_EXITING}
            style={styles.containerInner}
          >
            <DialogPrimitive.Popup
              style={(state) => [
                styles.content,
                {
                  borderColor: withAlpha(
                    colors.foreground,
                    isDark ? 0.1 : 0.05
                  ),
                  backgroundColor: colors.popover,
                },
                typeof style === "function" ? style(state) : style,
              ]}
              {...props}
            >
              <DialogCloseContext.Provider value={closeContext}>
                {children}
              </DialogCloseContext.Provider>
              {showCloseButton ? (
                <DialogCloseButton closeLabel={closeLabel} />
              ) : null}
            </DialogPrimitive.Popup>
          </AnimatedView>
        </View>
      </FullWindowOverlay>
    </DialogPortal>
  )
}

function DialogHeader({ style, ...props }: ViewProps) {
  const { hasCloseButton, hasCloseLabel } = React.useContext(DialogCloseContext)
  return (
    <View
      style={[
        styles.header,
        hasCloseButton && styles.headerClose,
        hasCloseLabel && styles.headerCloseLabel,
        style,
      ]}
      {...props}
    />
  )
}

function DialogFooter({
  style,
  showCloseButton = false,
  children,
  ...props
}: ViewProps & { showCloseButton?: boolean }) {
  const { width } = useWindowDimensions()
  return (
    <View
      style={[styles.footer, width >= 640 && styles.footerSm, style]}
      {...props}
    >
      {children}
      {showCloseButton ? (
        <DialogPrimitive.Close render={<Button variant="secondary" />}>
          <Text>Close</Text>
        </DialogPrimitive.Close>
      ) : null}
    </View>
  )
}

function DialogTitle({
  style,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
  const { colors } = useTheme()
  return (
    <DialogPrimitive.Title
      style={(state) => [
        styles.title,
        { color: colors.foreground },
        typeof style === "function" ? style(state) : style,
      ]}
      {...props}
    />
  )
}

function DialogDescription({
  style,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
  const { colors } = useTheme()
  return (
    <DialogPrimitive.Description
      style={(state) => [
        styles.description,
        { color: colors.mutedForeground },
        typeof style === "function" ? style(state) : style,
      ]}
      {...props}
    />
  )
}

export {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogOverlay,
  DialogPortal,
  DialogTitle,
  DialogTrigger,
}
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

```swift title="swift/Components/Dialog.swift"
import SwiftUI

extension View {
  public func dialog<Content: View>(
    isPresented: Binding<Bool>,
    @ViewBuilder content: @escaping () -> Content
  ) -> some View {
    modifier(
      CoverPresenter(isPresented: isPresented) { visible in
        CenteredStage(visible: visible, dismissible: true, content: content)
      }
    )
  }
}

struct CenteredStage<Content: View>: View {
  let visible: Bool
  let dismissible: Bool
  @ViewBuilder let content: () -> Content

  @Environment(\.overlayDismiss) private var dismiss
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  var body: some View {
    ZStack {
      ScrimLayer(visible: visible, dismissible: dismissible)
      content()
        .scaleEffect(visible || reduceMotion ? 1 : 0.95)
        .opacity(visible ? 1 : 0)
        .padding(16)
    }
    .accessibilityAction(.escape) { dismiss?() }
  }
}

extension EnvironmentValues {
  @Entry var overlayCloseInset: CGFloat = 0
}

public struct DialogContent<Content: View>: View {
  let showCloseButton: Bool
  let closeLabel: LocalizedStringKey?
  let content: Content

  public init(
    showCloseButton: Bool = true,
    closeLabel: LocalizedStringKey? = nil,
    @ViewBuilder content: () -> Content
  ) {
    self.showCloseButton = showCloseButton
    self.closeLabel = closeLabel
    self.content = content()
  }

  public var body: some View {
    VStack(alignment: .leading, spacing: 24) {
      content
    }
    .padding(24)
    .frame(maxWidth: 448, alignment: .leading)
    .environment(\.overlayCloseInset, showCloseButton ? (closeLabel == nil ? 32 : 72) : 0)
    .popupSurface(radius: Radius.xl2)
    .overlay(alignment: .topTrailing) {
      if showCloseButton {
        OverlayCloseButton(label: closeLabel)
          .padding(.top, closeLabel == nil ? 19 : 17)
          .padding(.trailing, 16)
      }
    }
    .accessibilityElement(children: .contain)
    .accessibilityAddTraits(.isModal)
  }
}

public struct DialogHeader<Content: View>: View {
  let content: Content

  @Environment(\.overlayCloseInset) private var closeInset

  public init(@ViewBuilder content: () -> Content) {
    self.content = content()
  }

  public var body: some View {
    VStack(alignment: .leading, spacing: 6) {
      content
    }
    .padding(.trailing, closeInset)
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}

public struct DialogFooter<Content: View>: View {
  let showCloseButton: Bool
  let content: Content

  public init(showCloseButton: Bool = false, @ViewBuilder content: () -> Content) {
    self.showCloseButton = showCloseButton
    self.content = content()
  }

  public var body: some View {
    ReversedVStack(spacing: 8) {
      content
      if showCloseButton {
        DialogClose {
          Text("Close").frame(maxWidth: .infinity)
        }
        .buttonStyle(.variant(.secondary))
      }
    }
  }
}

extension DialogFooter where Content == EmptyView {
  public init(showCloseButton: Bool) {
    self.init(showCloseButton: showCloseButton) { EmptyView() }
  }
}

public struct DialogTitle: View {
  let text: Text

  public init(_ title: LocalizedStringKey) {
    text = Text(title)
  }

  public init(verbatim title: String) {
    text = Text(verbatim: title)
  }

  public var body: some View {
    text
      .textStyle(.base, weight: .semibold)
      .foregroundStyle(EsuissColor.foreground)
      .fixedSize(horizontal: false, vertical: true)
      .accessibilityAddTraits(.isHeader)
  }
}

public struct DialogDescription: View {
  let text: Text

  public init(_ description: LocalizedStringKey) {
    text = Text(description)
  }

  public init(verbatim description: String) {
    text = Text(verbatim: description)
  }

  public var body: some View {
    text
      .textStyle(.sm)
      .foregroundStyle(EsuissColor.mutedForeground)
      .fixedSize(horizontal: false, vertical: true)
  }
}

public struct DialogClose<Label: View>: View {
  let action: (() -> Void)?
  let label: Label

  @Environment(\.overlayDismiss) private var dismiss

  public init(action: (() -> Void)? = nil, @ViewBuilder label: () -> Label) {
    self.action = action
    self.label = label()
  }

  public var body: some View {
    Button {
      action?()
      dismiss?()
    } label: {
      label
    }
  }
}

extension DialogClose where Label == Text {
  public init(_ title: LocalizedStringKey, action: (() -> Void)? = nil) {
    self.init(action: action) { Text(title) }
  }
}
```

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Overlay.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/Overlay.swift)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/components/Dialog.kt"
package com.esuiss.ui.components

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.semantics.paneTitle
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

@Immutable
private data class DialogCloseInfo(val hasCloseButton: Boolean, val hasCloseLabel: Boolean, val close: () -> Unit)

private val LocalDialogClose = staticCompositionLocalOf { DialogCloseInfo(false, false) {} }

@Composable
internal fun CenteredModal(
    open: Boolean,
    onDismissRequest: () -> Unit,
    dismissOnScrimClick: Boolean,
    maxWidth: Dp,
    modifier: Modifier,
    overlay: @Composable () -> Unit = {},
    content: @Composable ColumnScope.() -> Unit,
) {
    val transition = remember { MutableTransitionState(false) }
    transition.targetState = open
    val paneTitle = remember { PaneTitleState() }
    val colors = EsuissTheme.colors
    ModalWindow(
        visible = transition.currentState || transition.targetState,
        onDismissRequest = { if (open) onDismissRequest() },
        dismissOnBackPress = true,
        paneTitle = paneTitle.value,
    ) {
        Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
            Scrim(transition, 150, 100, onClick = if (dismissOnScrimClick) ({ if (open) onDismissRequest() }) else null)
            AnimatedVisibility(
                visibleState = transition,
                modifier = Modifier
                    .windowInsetsPadding(WindowInsets.safeDrawing)
                    .padding(16.dp),
                enter = fadeIn(tween(150)) + scaleIn(tween(150), initialScale = 0.95f),
                exit = fadeOut(tween(100)) + scaleOut(tween(100), targetScale = 0.95f),
            ) {
                val shape = RoundedCornerShape(Radius.xl2)
                Box(
                    modifier = modifier
                        .widthIn(max = maxWidth)
                        .fillMaxWidth()
                        .raisedSurface(colors, shape)
                        .pointerInput(Unit) {}
                        .semantics { paneTitle.value?.let { this.paneTitle = it } },
                ) {
                    CompositionLocalProvider(
                        LocalPaneTitle provides paneTitle,
                        LocalContentStyle provides Typography.base.copy(color = colors.foreground),
                        LocalContentColor provides colors.foreground,
                    ) {
                        Column(
                            modifier = Modifier
                                .fillMaxWidth()
                                .verticalScroll(rememberScrollState())
                                .padding(24.dp),
                            verticalArrangement = Arrangement.spacedBy(24.dp),
                            content = content,
                        )
                        overlay()
                    }
                }
            }
        }
    }
}

@Composable
fun Dialog(
    open: Boolean,
    onOpenChange: (Boolean) -> Unit,
    modifier: Modifier = Modifier,
    showCloseButton: Boolean = true,
    closeLabel: String? = null,
    dismissible: Boolean = true,
    content: @Composable ColumnScope.() -> Unit,
) {
    val close = { onOpenChange(false) }
    val info = DialogCloseInfo(showCloseButton, showCloseButton && closeLabel != null, close)
    CompositionLocalProvider(LocalDialogClose provides info) {
        CenteredModal(
            open = open,
            onDismissRequest = { if (dismissible) close() },
            dismissOnScrimClick = dismissible,
            maxWidth = 448.dp,
            modifier = modifier,
            overlay = {
                if (showCloseButton) {
                    Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.TopEnd) {
                        OverlayCloseButton(
                            onClick = close,
                            label = closeLabel,
                            modifier = Modifier.padding(
                                top = if (closeLabel != null) 19.dp else 13.dp,
                                end = if (closeLabel != null) 16.dp else 10.dp,
                            ),
                        )
                    }
                }
            },
            content = content,
        )
    }
}

@Composable
fun DialogHeader(
    modifier: Modifier = Modifier,
    content: @Composable ColumnScope.() -> Unit,
) {
    val info = LocalDialogClose.current
    val end = when {
        info.hasCloseLabel -> 72.dp
        info.hasCloseButton -> 32.dp
        else -> 0.dp
    }
    Column(
        modifier = modifier.fillMaxWidth().padding(end = end),
        verticalArrangement = Arrangement.spacedBy(6.dp),
        content = content,
    )
}

@Composable
fun DialogFooter(
    modifier: Modifier = Modifier,
    showCloseButton: Boolean = false,
    content: @Composable () -> Unit,
) {
    val info = LocalDialogClose.current
    Layout(
        modifier = modifier.fillMaxWidth(),
        content = {
            content()
            if (showCloseButton) {
                Button("Close", onClick = info.close, modifier = Modifier.fillMaxWidth(), variant = ButtonVariant.Secondary)
            }
        },
    ) { measurables, constraints ->
        val gap = 8.dp.roundToPx()
        val placeables = measurables.map { it.measure(constraints.copy(minHeight = 0)) }
        val height = placeables.sumOf { it.height } + gap * (placeables.size - 1).coerceAtLeast(0)
        layout(constraints.maxWidth, height) {
            var y = 0
            placeables.asReversed().forEach {
                it.placeRelative(0, y)
                y += it.height + gap
            }
        }
    }
}

@Composable
fun DialogTitle(text: String, modifier: Modifier = Modifier) {
    OverlayTitle(text, modifier, titleStyle(EsuissTheme.colors))
}

@Composable
fun DialogDescription(text: String, modifier: Modifier = Modifier) {
    BasicText(text, modifier = modifier, style = descriptionStyle(EsuissTheme.colors))
}
```

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/Overlay.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Overlay.kt)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { DialogOverlay, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription } from "@/components/ui/dialog"
```

```tsx
<View className="items-start">
  <Dialog defaultOpen>
    <DialogTrigger render={<Button variant="outline" />}>
      <Text>Edit profile</Text>
    </DialogTrigger>
    <EditProfileContent />
  </Dialog>
</View>
```

Compose `DialogTrigger` and `DialogContent` with a `DialogHeader` (`DialogTitle`, `DialogDescription`), your content, and a `DialogFooter`. Footer buttons stack vertically with the first child at the bottom, and sit in a row aligned to the end on wider screens. Use `DialogClose` with `render` to turn any `Button` into a close button.

Set `showCloseButton={false}` on the content to hide the corner button, or pass `closeLabel` to show a text button such as "Done" instead of the icon. The header adds end padding so the title does not run under it. `DialogFooter` takes `showCloseButton` to append a secondary Close button.

Use `open` and `onOpenChange` for controlled state, or `defaultOpen` for uncontrolled.

## Examples

### Default

![Preview](https://ui.suiss.app/previews/components-dialog--default.light.webp)

```tsx
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Text } from "@/components/ui/text"

function EditProfileContent() {
  return (
    <DialogContent>
      <DialogHeader>
        <DialogTitle>Edit profile</DialogTitle>
        <DialogDescription>
          Make changes to your profile here. Tap save when you&apos;re done.
        </DialogDescription>
      </DialogHeader>
      <View className="gap-4">
        <View className="gap-2">
          <Label nativeID="dialog-name">Name</Label>
          <Input
            accessibilityLabelledBy="dialog-name"
            defaultValue="Pedro Duarte"
          />
        </View>
        <View className="gap-2">
          <Label nativeID="dialog-username">Username</Label>
          <Input
            accessibilityLabelledBy="dialog-username"
            defaultValue="@peduarte"
            autoCapitalize="none"
          />
        </View>
      </View>
      <DialogFooter>
        <DialogClose render={<Button variant="secondary" />}>
          <Text>Cancel</Text>
        </DialogClose>
        <Button>
          <Text>Save changes</Text>
        </Button>
      </DialogFooter>
    </DialogContent>
  )
}

export function DialogExample() {
  return (
    <View className="items-start">
      <Dialog>
        <DialogTrigger render={<Button variant="outline" />}>
          <Text>Edit profile</Text>
        </DialogTrigger>
        <EditProfileContent />
      </Dialog>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct DialogEditProfile: View {
  @State private var open: Bool

  init(open: Bool = false) {
    _open = State(initialValue: open)
  }

  var body: some View {
    Button("Edit profile") { open = true }
      .buttonStyle(.variant(.outline))
      .dialog(isPresented: $open) {
        DialogEditProfileContent()
      }
  }
}

struct DialogEditProfileContent: View {
  @State private var name = "Pedro Duarte"
  @State private var username = "@peduarte"

  var body: some View {
    DialogContent {
      DialogHeader {
        DialogTitle("Edit profile")
        DialogDescription("Make changes to your profile here. Tap save when you're done.")
      }
      VStack(alignment: .leading, spacing: 16) {
        VStack(alignment: .leading, spacing: 8) {
          FieldLabel("Name")
            .accessibilityHidden(true)
          TextField("Name", text: $name)
            .textFieldStyle(.input)
        }
        VStack(alignment: .leading, spacing: 8) {
          FieldLabel("Username")
            .accessibilityHidden(true)
          TextField("Username", text: $username)
            .textFieldStyle(.input)
            .textInputAutocapitalization(.never)
        }
      }
      DialogFooter {
        DialogClose {
          Text("Cancel").frame(maxWidth: .infinity)
        }
        .buttonStyle(.variant(.secondary))
        Button {} label: {
          Text("Save changes").frame(maxWidth: .infinity)
        }
        .buttonStyle(.variant())
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Dialog
import com.esuiss.ui.components.DialogDescription
import com.esuiss.ui.components.DialogFooter
import com.esuiss.ui.components.DialogHeader
import com.esuiss.ui.components.DialogTitle
import com.esuiss.ui.components.Input
import com.esuiss.ui.components.Label

@Composable
fun DialogExample() {
    DialogDefault(defaultOpen = false)
}

@Composable
fun DialogDefault(defaultOpen: Boolean) {
    var open by rememberSaveable { mutableStateOf(defaultOpen) }
    var name by rememberSaveable { mutableStateOf("Pedro Duarte") }
    var username by rememberSaveable { mutableStateOf("@peduarte") }
    Column {
        Button("Edit profile", onClick = { open = true }, variant = ButtonVariant.Outline)
        Dialog(open = open, onOpenChange = { open = it }) {
            DialogHeader {
                DialogTitle("Edit profile")
                DialogDescription("Make changes to your profile here. Tap save when you're done.")
            }
            Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
                Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                    Label("Name")
                    Input(name, { name = it }, Modifier.semantics { contentDescription = "Name" })
                }
                Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                    Label("Username")
                    Input(
                        username,
                        { username = it },
                        Modifier.semantics { contentDescription = "Username" },
                        keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None),
                    )
                }
            }
            DialogFooter {
                Button("Cancel", onClick = { open = false }, modifier = Modifier.fillMaxWidth(), variant = ButtonVariant.Secondary)
                Button("Save changes", onClick = {}, modifier = Modifier.fillMaxWidth())
            }
        }
    }
}
```

### Destructive

A destructive action with a cancel button in the footer.

![Preview](https://ui.suiss.app/previews/components-dialog--destructive.light.webp)

```tsx
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Text } from "@/components/ui/text"

export function DialogDestructiveExample() {
  return (
    <View className="items-start">
      <Dialog>
        <DialogTrigger render={<Button variant="destructive" />}>
          <Text>Delete account</Text>
        </DialogTrigger>
        <DialogContent>
          <DialogHeader>
            <DialogTitle>Delete account</DialogTitle>
            <DialogDescription>
              This will permanently delete your account and all of its data.
            </DialogDescription>
          </DialogHeader>
          <DialogFooter>
            <DialogClose render={<Button variant="secondary" />}>
              <Text>Cancel</Text>
            </DialogClose>
            <DialogClose render={<Button variant="destructive" />}>
              <Text>Delete</Text>
            </DialogClose>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct DialogDestructive: View {
  @State private var open = false

  var body: some View {
    Button("Delete account") { open = true }
      .buttonStyle(.variant(.destructive))
      .dialog(isPresented: $open) {
        DialogContent {
          DialogHeader {
            DialogTitle("Delete account")
            DialogDescription("This will permanently delete your account and all of its data.")
          }
          DialogFooter {
            DialogClose {
              Text("Cancel").frame(maxWidth: .infinity)
            }
            .buttonStyle(.variant(.secondary))
            DialogClose {
              Text("Delete").frame(maxWidth: .infinity)
            }
            .buttonStyle(.variant(.destructive))
          }
        }
      }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Dialog
import com.esuiss.ui.components.DialogDescription
import com.esuiss.ui.components.DialogFooter
import com.esuiss.ui.components.DialogHeader
import com.esuiss.ui.components.DialogTitle

@Composable
fun DialogDestructive() {
    var open by rememberSaveable { mutableStateOf(false) }
    Column {
        Button("Delete account", onClick = { open = true }, variant = ButtonVariant.Destructive)
        Dialog(open = open, onOpenChange = { open = it }) {
            DialogHeader {
                DialogTitle("Delete account")
                DialogDescription("This will permanently delete your account and all of its data.")
            }
            DialogFooter {
                Button("Cancel", onClick = { open = false }, modifier = Modifier.fillMaxWidth(), variant = ButtonVariant.Secondary)
                Button("Delete", onClick = { open = false }, modifier = Modifier.fillMaxWidth(), variant = ButtonVariant.Destructive)
            }
        }
    }
}
```

### Without close button

No corner button, with a Close button in the footer.

![Preview](https://ui.suiss.app/previews/components-dialog--without-close-button.light.webp)

```tsx
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Text } from "@/components/ui/text"

export function DialogWithoutCloseButtonExample() {
  return (
    <View className="items-start">
      <Dialog>
        <DialogTrigger render={<Button variant="outline" />}>
          <Text>Show terms</Text>
        </DialogTrigger>
        <DialogContent showCloseButton={false}>
          <DialogHeader>
            <DialogTitle>Terms updated</DialogTitle>
            <DialogDescription>
              We have updated our terms of service. Please review the changes
              before continuing.
            </DialogDescription>
          </DialogHeader>
          <DialogFooter showCloseButton>
            <Button>
              <Text>Accept</Text>
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct DialogWithoutCloseButton: View {
  @State private var open = false

  var body: some View {
    Button("Show terms") { open = true }
      .buttonStyle(.variant(.outline))
      .dialog(isPresented: $open) {
        DialogContent(showCloseButton: false) {
          DialogHeader {
            DialogTitle("Terms updated")
            DialogDescription("We have updated our terms of service. Please review the changes before continuing.")
          }
          DialogFooter(showCloseButton: true) {
            Button {} label: {
              Text("Accept").frame(maxWidth: .infinity)
            }
            .buttonStyle(.variant())
          }
        }
      }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Dialog
import com.esuiss.ui.components.DialogDescription
import com.esuiss.ui.components.DialogFooter
import com.esuiss.ui.components.DialogHeader
import com.esuiss.ui.components.DialogTitle

@Composable
fun DialogWithoutCloseButton() {
    var open by rememberSaveable { mutableStateOf(false) }
    Column {
        Button("Show terms", onClick = { open = true }, variant = ButtonVariant.Outline)
        Dialog(open = open, onOpenChange = { open = it }, showCloseButton = false) {
            DialogHeader {
                DialogTitle("Terms updated")
                DialogDescription("We have updated our terms of service. Please review the changes before continuing.")
            }
            DialogFooter(showCloseButton = true) {
                Button("Accept", onClick = {}, modifier = Modifier.fillMaxWidth())
            }
        }
    }
}
```

### With close label

A text close button through `closeLabel`.

![Preview](https://ui.suiss.app/previews/components-dialog--with-close-label.light.webp)

```tsx
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Text } from "@/components/ui/text"

export function DialogWithCloseLabelExample() {
  return (
    <View className="items-start">
      <Dialog>
        <DialogTrigger render={<Button variant="outline" />}>
          <Text>Open settings</Text>
        </DialogTrigger>
        <DialogContent closeLabel="Done">
          <DialogHeader>
            <DialogTitle>Notification settings</DialogTitle>
            <DialogDescription>
              Choose how you want to be notified about activity in your
              workspace.
            </DialogDescription>
          </DialogHeader>
        </DialogContent>
      </Dialog>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct DialogWithCloseLabel: View {
  @State private var open = false

  var body: some View {
    Button("Open settings") { open = true }
      .buttonStyle(.variant(.outline))
      .dialog(isPresented: $open) {
        DialogContent(closeLabel: "Done") {
          DialogHeader {
            DialogTitle("Notification settings")
            DialogDescription("Choose how you want to be notified about activity in your workspace.")
          }
        }
      }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Dialog
import com.esuiss.ui.components.DialogDescription
import com.esuiss.ui.components.DialogHeader
import com.esuiss.ui.components.DialogTitle

@Composable
fun DialogWithCloseLabel() {
    var open by rememberSaveable { mutableStateOf(false) }
    Column {
        Button("Open settings", onClick = { open = true }, variant = ButtonVariant.Outline)
        Dialog(open = open, onOpenChange = { open = it }, closeLabel = "Done") {
            DialogHeader {
                DialogTitle("Notification settings")
                DialogDescription("Choose how you want to be notified about activity in your workspace.")
            }
        }
    }
}
```

## API Reference

### Dialog

The root that manages the open state.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `open` | `boolean` | - | Whether the dialog is open, for controlled use. |
| `defaultOpen` | `boolean` | `false` | Whether the dialog starts open when uncontrolled. |
| `onOpenChange` | `(open: boolean, details) => void` | - | Called when the dialog opens or closes. |
| `modal` | `boolean` | `true` | Blocks interaction with the rest of the screen while open. |
| `disablePointerDismissal` | `boolean` | `false` | Keeps the dialog open when the overlay is tapped. |

### DialogOverlay

Dimmed scrim that closes the dialog when tapped.

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

### DialogContent

Centered card with an optional close button.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `portalHost` | `string` | - | Name of the portal host to render into. |
| `showCloseButton` | `boolean` | `true` | Shows the corner close button. Defaults to `true`. |
| `closeLabel` | `string` | - | Text for the close button instead of the icon. |

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

### DialogHeader

Column for the title and description.

Also accepts the props of `ViewProps`.

### DialogFooter

Area for actions.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `showCloseButton` | `boolean` | `false` | Appends a secondary Close button. |

Also accepts the props of `ViewProps`.

### DialogTitle

Dialog heading.

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

### DialogDescription

Supporting text.

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

## Accessibility

- The popup has the `dialog` role, is labelled by the title and is marked as modal for VoiceOver.
- Focus moves to the title when the dialog opens.
- The Android back button and the VoiceOver escape gesture close it.
- The icon close button is labelled "Close".

## Notes

- On iOS the content is wrapped in a full-window overlay so it appears above native navigation.
- Animations respect the system Reduce Motion setting.
- Uses `bg-popover`, `bg-black/50` and `bg-secondary` tokens; light and dark follow the system appearance.

