ResponsiveModal

Adaptive component that renders as a modal on desktop and drawer on mobile

Installation

pnpm add @wandercom/design-system-web

ResponsiveModal combines Modal (Radix Dialog) and Drawer (Base UI) to provide responsive behavior.

Usage

The ResponsiveModal component adapts between Modal and Drawer based on viewport size by default. Pass containerRef when an embedding container, such as a component preview, should determine the mode instead.

25 lines
import { useState } from 'react';
import { ResponsiveModal } from '@wandercom/design-system-web/ui/responsive-modal';
import { Button } from '@wandercom/design-system-web/ui/button';

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

  return (
    <ResponsiveModal
      open={open}
      onOpenChange={setOpen}
      trigger={<Button>Open</Button>}
      title="Edit profile"
      description="Make changes to your profile here. Click save when you're done."
      footer={
        <>
          <Button variant="outline">Cancel</Button>
          <Button variant="primary">Save changes</Button>
        </>
      }
    >
      <div>Form content here</div>
    </ResponsiveModal>
  );
}

Examples

Loading example...

Control the open state when a parent needs to react to close events. ResponsiveModal closes when its measured width crosses the active modal/drawer breakpoint, including while controlled:

18 lines
import { useState } from 'react';
import { ResponsiveModal } from '@wandercom/design-system-web/ui/responsive-modal';

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

  return (
    <ResponsiveModal
      open={open}
      onOpenChange={setOpen}
      trigger={<button>Open</button>}
      title="Responsive content"
    >
      <p>This appears as a modal on desktop and drawer on mobile</p>
      <button onClick={() => setOpen(false)}>Close</button>
    </ResponsiveModal>
  );
}

Responsive behavior

The ResponsiveModal uses viewport size by default, or the supplied containerRef width when one is provided:

  • Viewport-driven desktop (≥744px): Renders as a centered modal dialog
  • Viewport-driven mobile (<744px): Renders as a bottom drawer
  • Container-driven desktop (≥1040px): Renders as a centered modal dialog
  • Container-driven mobile (<1040px): Renders as a bottom drawer

This provides an optimal experience across devices without requiring manual detection or separate implementations.

Smooth resize transitions

When the viewport or observed container is resized while ResponsiveModal is open, the component closes before switching modes. Container-driven instances switch mode when their observed container crosses 1040px.

Slots pattern

Use the slots prop to customize styles for specific parts:

11 lines
<ResponsiveModal
  trigger={<button>Open</button>}
  title="Custom styles"
  slots={{
    content: "max-w-2xl",
    title: "text-2xl",
    description: "text-muted"
  }}
>
  Content here
</ResponsiveModal>

When to use

Use ResponsiveModal when you need a single implementation that works well across all devices:

  • Form submissions and data entry
  • Confirmation dialogs
  • Settings and preferences
  • Content that requires user interaction
  • Any modal experience that should adapt to device size

If you need explicit control over desktop or mobile behavior, use the Modal or Drawer components directly.

Props

ResponsiveModal

trigger?

React.ReactNode
Element that triggers the modal or drawer to open.

title?

React.ReactNode
Modal or drawer title content.

description?

React.ReactNode
Modal or drawer description content.

primaryAction?

{ label: string; action: () => void }
Primary action button configuration. Automatically rendered in footer unless footer prop is provided.

secondaryAction?

{ label: string; action: () => void }
Secondary action button configuration. Automatically rendered in footer unless footer prop is provided.

variant?

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

size?

'default' | 'wide'
Size variant for modal on desktop. Only applies to modal view, not drawer. Defaults to default.

formId?

string
Form ID to associate with the primary action button, converting it to a submit button.

children?

React.ReactNode
Main modal or drawer content.

open?

boolean
Controlled open state of the modal or drawer.

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 the modal or drawer is opened and focus is automatically set.

onCloseAutoFocus?

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

slots?

ModalSlots | DrawerSlots
Slot-based className overrides for subcomponents. Available slots: trigger, overlay, content, header, footer, title, description.

containerRef?:

React.RefObject<HTMLElement | null>
Container measured instead of the viewport when selecting the modal or drawer layout.

className?

string
Additional CSS classes for the content container.

Breakpoint

Without containerRef, the viewport breakpoint remains 744px:

  • Desktop: min-width: 744px → Modal (centered dialog)
  • Mobile: max-width: 743px → Drawer (bottom sheet)

With containerRef, the container-query breakpoint is 65rem (1040px):

  • Desktop: min-width: 65rem → Modal (centered dialog)
  • Mobile: below 65rem → Drawer (bottom sheet)

Accessibility

The ResponsiveModal component includes proper accessibility features across both modal and drawer modes:

  • Automatically manages focus when opened and closed
  • Traps focus within the content when open
  • Closes on escape key press
  • Supports click outside to close
  • Uses aria-labelledby to link title to content
  • Uses aria-describedby to link description to content
  • Returns focus to trigger element when closed
  • Provides appropriate animations for visual feedback
ResponsiveModal