Modal

Modal dialog for presenting focused content and actions

Installation

pnpm add @wandercom/design-system-web

Usage

The Modal component provides both a high-level composable API and low-level primitives for advanced use cases. The modal includes a built-in close button in the top-right corner, a backdrop overlay, and fade/zoom animations.

High-level API

Use the simplified Modal component with slots for most use cases:

import { Modal } from '@wandercom/design-system-web/ui/modal';
import { Button } from '@wandercom/design-system-web/ui/button';

export function Example() {
  return (
    <Modal
      trigger={<Button>Open</Button>}
      title="Account Settings"
      description="Manage your account preferences"
      primaryAction={{ label: "Save", action: () => console.log("saved") }}
      secondaryAction={{ label: "Cancel", action: () => console.log("cancelled") }}
    >
      <div>Content here</div>
    </Modal>
  );
}

Primitive API

For advanced composition, use the individual Modal primitives:

import {
  ModalRoot,
  ModalTrigger,
  ModalClose,
  ModalOverlay,
  ModalPortal,
  ModalContent,
  ModalHeader,
  ModalFooter,
  ModalTitle,
  ModalDescription,
} from '@wandercom/design-system-web/ui/modal';

export function Example() {
  return (
    <ModalRoot>
      <ModalTrigger asChild>
        <button>Open</button>
      </ModalTrigger>
      <ModalPortal>
        <ModalOverlay />
        <ModalContent>
          <ModalClose />
          <ModalHeader>
            <ModalTitle>Account Settings</ModalTitle>
            <ModalDescription>Manage your account preferences</ModalDescription>
          </ModalHeader>
          <div>Content here</div>
          <ModalFooter>
            <button>Cancel</button>
            <button>Save</button>
          </ModalFooter>
        </ModalContent>
      </ModalPortal>
    </ModalRoot>
  );
}

Examples

Loading example...

Size variants

Default

The default size constrains the modal to a max width of 520px. Content, header, and footer are all rendered inline.

<Modal
  trigger={<Button>Open</Button>}
  title="Account Settings"
  description="Manage your account preferences"
  primaryAction={{ label: "Save", action: handleSave }}
  secondaryAction={{ label: "Cancel", action: handleCancel }}
>
  <div>Form fields here</div>
</Modal>

Wide

The size="wide" variant constrains modal height (430-704px), scrolls header and body content together, and pins the footer with a border separator. The secondary action renders as a ghost button in this variant.

<Modal
  size="wide"
  trigger={<Button>Open</Button>}
  title="Edit Property Details"
  description="Update property information"
  primaryAction={{ label: "Save changes", action: handleSave }}
  secondaryAction={{ label: "Cancel", action: handleCancel }}
>
  <div>Scrollable content here</div>
</Modal>

Controlled state

Control the modal open state programmatically using the open and onOpenChange props:

import { useState } from 'react';
import { Modal } from '@wandercom/design-system-web/ui/modal';

export function ControlledModal() {
  const [open, setOpen] = useState(false);

  return (
    <Modal
      open={open}
      onOpenChange={setOpen}
      trigger={<button>Open modal</button>}
      title="Account Settings"
      primaryAction={{ label: "Save", action: () => setOpen(false) }}
      secondaryAction={{ label: "Cancel", action: () => setOpen(false) }}
    >
      <p>Modal content</p>
    </Modal>
  );
}

Action buttons

Use primaryAction and secondaryAction for simple button configurations:

<Modal
  trigger={<button>Open</button>}
  title="Confirm action"
  primaryAction={{ label: "Save", action: () => console.log("saved") }}
  secondaryAction={{ label: "Cancel", action: () => console.log("cancelled") }}
>
  <p>Are you sure you want to save these changes?</p>
</Modal>

Use the variant prop to make the primary action destructive:

<Modal
  trigger={<button>Delete</button>}
  title="Delete item"
  variant="destructive"
  primaryAction={{ label: "Delete", action: () => console.log("deleted") }}
  secondaryAction={{ label: "Cancel", action: () => console.log("cancelled") }}
>
  <p>This action cannot be undone.</p>
</Modal>

Form integration

Associate the primary action with a form using formId. The primary action button becomes a submit button linked to the specified form:

<Modal
  trigger={<button>Edit profile</button>}
  title="Edit Profile"
  formId="profile-form"
  primaryAction={{ label: "Save changes", action: () => {} }}
>
  <form id="profile-form" onSubmit={handleSubmit}>
    <input type="text" name="name" />
  </form>
</Modal>

Non-dismissible modals

Set closable={false} to prevent dismissal via the close button, overlay click, or escape key. Use this for forced flows where the user must complete an action:

<Modal
  closable={false}
  title="Terms of Service"
  description="You must accept the terms to continue."
  primaryAction={{ label: "Accept", action: handleAccept }}
>
  <div>Terms content here</div>
</Modal>

Slots pattern

Use the slots prop to customize styles for specific parts of the modal:

<Modal
  trigger={<button>Open</button>}
  title="Custom styles"
  slots={{
    content: "max-w-lg",
    overlay: "bg-black/40",
    title: "text-2xl",
    description: "text-muted",
    header: "pb-4",
    footer: "pt-4",
  }}
>
  Content here
</Modal>

Props

trigger?

React.ReactNode
Element that triggers the modal to open.

title?

React.ReactNode
Modal title content.

description?

React.ReactNode
Modal description content.

primaryAction?

{ label: string; action: () => void, disabled?: boolean, loading?: boolean }
Configuration for the primary action button.

secondaryAction?

{ label: string; action: () => void, disabled?: boolean, loading?: boolean }
Configuration for the secondary action button.

variant?

'default' | 'destructive'
Visual variant affecting primary action button style. Defaults to "default".

size?

'default' | 'wide'
Modal size. "wide" enables scrollable content with a pinned footer. Defaults to "default".

formId?

string
Form ID to associate with the primary action button for form submission.

closable?

boolean
Whether the modal can be dismissed via close button, overlay click, or escape key. Defaults to true.

children?

React.ReactNode
Main modal content.

open?

boolean
Controlled open state of the modal.

onOpenChange?

(open: boolean) => void
Event handler called when the open state changes.

defaultOpen?

boolean
Default open state for uncontrolled usage.

onOpenAutoFocus?

(event: Event) => void
Event handler called when focus moves into the modal on open.

onCloseAutoFocus?

(event: Event) => void
Event handler called when focus returns to the trigger on close.

slots?

ModalSlots
Slot-based className overrides for subcomponents (trigger, overlay, content, header, footer, title, description).

className?

string
Additional CSS classes for the content container.

ModalContent

className?

string
Additional CSS classes to apply to the content container.

onOpenAutoFocus?

(event: Event) => void
Event handler called when the modal is opened and focus is set.

onCloseAutoFocus?

(event: Event) => void
Event handler called when the modal is closed and focus is returned.

ModalHeader

className?

string
Additional CSS classes to apply to the header container.

ModalFooter

className?

string
Additional CSS classes to apply to the footer container.

ModalTitle

className?

string
Additional CSS classes to apply to the title element.

ModalDescription

className?

string
Additional CSS classes to apply to the description element.

ModalOverlay

className?

string
Additional CSS classes to apply to the overlay backdrop.

ModalClose

className?

string
Additional CSS classes to apply to the close button.

Accessibility

  • Traps focus within the modal when open
  • Closes on escape key press and click outside (unless closable={false})
  • Links title and description via aria-labelledby / aria-describedby
  • Returns focus to trigger on close
Modal