Magic Native UI

Dialog

Dialog rendered in a native modal, with overlay and close button.

Installation

Terminal
npx magic-native-ui@latest add dialog

Usage

The example running in the preview above. Imports use the default aliases from components.json.

dialog-demo.tsx
import * as React from 'react'

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 DialogDemo() {
  const [open, setOpen] = React.useState(false)

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button variant="outline">
          <Text>Open dialog</Text>
        </Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Delete project</DialogTitle>
          <DialogDescription>
            This permanently removes the project and everything in it.
          </DialogDescription>
        </DialogHeader>
        <DialogFooter>
          <Button variant="outline" onPress={() => setOpen(false)}>
            <Text>Cancel</Text>
          </Button>
          <Button variant="destructive" onPress={() => setOpen(false)}>
            <Text>Delete</Text>
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

Component source

What the CLI copies into your project. Once it is there, the code is yours to edit.

registry/ui/dialog.tsx
import * as React from 'react'
import { X } from 'lucide-react-native'
import { Modal, Pressable, View, type GestureResponderEvent, type ViewProps } from 'react-native'

import { iconWithClassName } from '@/registry/lib/icons'
import { Text } from '@/registry/ui/text'
import { cn } from '@/registry/lib/utils'

const XIcon = iconWithClassName(X)

type PressableProps = React.ComponentProps<typeof Pressable>

type DialogContextValue = {
  open: boolean
  onOpenChange: (open: boolean) => void
}

const DialogContext = React.createContext<DialogContextValue | null>(null)

function useDialogContext(name: string) {
  const context = React.useContext(DialogContext)

  if (!context) {
    throw new Error(`<${name} /> must be rendered inside a <Dialog />`)
  }

  return context
}

/**
 * React Native has no Radix slot, so `asChild` clones the single child instead
 * of rendering a Pressable of its own — `<DialogTrigger asChild><Button /></DialogTrigger>`.
 * The child's own props win, and its `onPress` runs before the dialog's.
 */
function PressableSlot({ asChild, children, ...props }: PressableProps & { asChild?: boolean }) {
  if (!asChild) {
    return <Pressable {...props}>{children}</Pressable>
  }

  const child = React.Children.only(children) as React.ReactElement<PressableProps>

  return React.cloneElement(child, {
    ...props,
    ...child.props,
    onPress: (event: GestureResponderEvent) => {
      child.props.onPress?.(event)
      props.onPress?.(event)
    },
  })
}

type DialogProps = ViewProps & {
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
}

function Dialog({ open: openProp, defaultOpen = false, onOpenChange, ...props }: DialogProps) {
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
  const open = openProp ?? uncontrolledOpen

  const handleOpenChange = React.useCallback(
    (next: boolean) => {
      if (openProp === undefined) {
        setUncontrolledOpen(next)
      }

      onOpenChange?.(next)
    },
    [openProp, onOpenChange]
  )

  const value = React.useMemo(
    () => ({ open, onOpenChange: handleOpenChange }),
    [open, handleOpenChange]
  )

  return (
    <DialogContext.Provider value={value}>
      <View {...props} />
    </DialogContext.Provider>
  )
}

type DialogTriggerProps = PressableProps & { asChild?: boolean }

function DialogTrigger({ asChild, disabled, onPress, ...props }: DialogTriggerProps) {
  const { open, onOpenChange } = useDialogContext('DialogTrigger')

  function handlePress(event: GestureResponderEvent) {
    onOpenChange(true)
    onPress?.(event)
  }

  return (
    <PressableSlot
      asChild={asChild}
      role="button"
      aria-expanded={open}
      aria-disabled={disabled ?? undefined}
      disabled={disabled}
      onPress={handlePress}
      {...props}
    />
  )
}

type DialogOverlayProps = PressableProps & {
  /** Pressing the overlay closes the dialog. */
  closeOnPress?: boolean
}

function DialogOverlay({ className, closeOnPress = true, onPress, ...props }: DialogOverlayProps) {
  const { onOpenChange } = useDialogContext('DialogOverlay')

  function handlePress(event: GestureResponderEvent) {
    if (closeOnPress) {
      onOpenChange(false)
    }

    onPress?.(event)
  }

  return (
    <Pressable
      accessible={false}
      onPress={handlePress}
      className={cn('absolute inset-0 z-50 items-center justify-center bg-black/80 p-4', className)}
      {...props}
    />
  )
}

type DialogContentProps = ViewProps

/**
 * React Native's own `Modal` does the job a portal does on the web: it lifts the
 * dialog above the rest of the tree, closes on the Android back button and on
 * Escape, and needs no host component at the root of the app.
 */
function DialogContent({ className, children, ...props }: DialogContentProps) {
  const { open, onOpenChange } = useDialogContext('DialogContent')

  return (
    <Modal
      visible={open}
      transparent
      animationType="fade"
      statusBarTranslucent
      onRequestClose={() => onOpenChange(false)}
    >
      <DialogOverlay>
        <View
          role="dialog"
          aria-modal={true}
          // Without this a press on the content reaches the overlay, which closes.
          onStartShouldSetResponder={() => true}
          className={cn(
            'z-50 w-full max-w-lg gap-4 rounded-lg border border-border bg-background p-6 shadow-lg',
            className
          )}
          {...props}
        >
          {children}
          <DialogClose className="absolute right-4 top-4 rounded-sm p-1 active:opacity-70">
            <XIcon size={18} className="text-muted-foreground" />
          </DialogClose>
        </View>
      </DialogOverlay>
    </Modal>
  )
}

function DialogHeader({ className, ...props }: ViewProps) {
  return <View className={cn('gap-1.5', className)} {...props} />
}

function DialogFooter({ className, ...props }: ViewProps) {
  return <View className={cn('flex-row justify-end gap-2', className)} {...props} />
}

function DialogTitle({ className, ...props }: React.ComponentProps<typeof Text>) {
  return (
    <Text
      role="heading"
      className={cn('text-lg font-semibold leading-none tracking-tight text-foreground', className)}
      {...props}
    />
  )
}

function DialogDescription({ className, ...props }: React.ComponentProps<typeof Text>) {
  return <Text className={cn('text-sm text-muted-foreground', className)} {...props} />
}

type DialogCloseProps = PressableProps & { asChild?: boolean }

function DialogClose({ asChild, onPress, ...props }: DialogCloseProps) {
  const { onOpenChange } = useDialogContext('DialogClose')

  function handlePress(event: GestureResponderEvent) {
    onOpenChange(false)
    onPress?.(event)
  }

  return <PressableSlot asChild={asChild} role="button" onPress={handlePress} {...props} />
}

export {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogOverlay,
  DialogTitle,
  DialogTrigger,
}
export type { DialogContentProps, DialogProps }