UTA DevHub
UI Component Examples/Patterns

Modal Pattern

Overlay dialog pattern composed from foundation components

Modal Pattern

Overview

The Modal pattern provides overlay dialogs for focused user interactions. It's composed from foundation components (Card, Button, Text) and handles backdrop interactions, animations, and accessibility.

GitHub Source: View the complete implementation in our repository.

Basic Usage

import { Modal } from '@/ui/patterns/Modal';
 
function MyComponent() {
  const [visible, setVisible] = useState(false);
  
  return (
    <>
      <Button onPress={() => setVisible(true)}>
        Open Modal
      </Button>
      
      <Modal
        visible={visible}
        onClose={() => setVisible(false)}
        title="Confirmation"
      >
        <Text>Are you sure you want to proceed?</Text>
      </Modal>
    </>
  );
}
<Modal
  visible={visible}
  onClose={onClose}
  title="Information"
>
  <Text>This is a simple informational modal.</Text>
</Modal>

Basic modals display information with a close button.

Composition

The Modal pattern is composed from foundation components:

// ui/patterns/Modal/Modal.tsx
import React from 'react';
import { Modal as RNModal, View, SafeAreaView } from 'react-native';
import { Card } from '@/ui/foundation/Card';
import { Text } from '@/ui/foundation/Text';
import { Button } from '@/ui/foundation/Button';
import { IconButton } from '@/ui/foundation/IconButton';
import { Divider } from '@/ui/foundation/Divider';
import { theme } from '@/core/shared/styles/theme';
import type { ModalProps } from './types';
 
export function Modal({
  visible,
  onClose,
  title,
  children,
  primaryButton,
  secondaryButton,
  fullScreen = false,
  ...props
}: ModalProps) {
  return (
    <RNModal
      visible={visible}
      transparent={!fullScreen}
      animationType="fade"
      onRequestClose={onClose}
      {...props}
    >
      <SafeAreaView style={styles.container}>
        <View style={styles.backdrop} onTouchEnd={onClose} />
        
        <Card style={[
          styles.modal,
          fullScreen && styles.fullScreenModal
        ]}>
          {/* Header */}
          {title && (
            <>
              <View style={styles.header}>
                <Text variant="heading" size="large">
                  {title}
                </Text>
                <IconButton
                  icon="close"
                  variant="ghost"
                  size="small"
                  onPress={onClose}
                />
              </View>
              <Divider />
            </>
          )}
          
          {/* Content */}
          <View style={styles.content}>
            {children}
          </View>
          
          {/* Actions */}
          {(primaryButton || secondaryButton) && (
            <>
              <Divider />
              <View style={styles.actions}>
                {secondaryButton && (
                  <Button {...secondaryButton} />
                )}
                {primaryButton && (
                  <Button {...primaryButton} />
                )}
              </View>
            </>
          )}
        </Card>
      </SafeAreaView>
    </RNModal>
  );
}

Advanced Features

Custom Animations

<Modal
  visible={visible}
  onClose={onClose}
  animationType="slide" // 'none' | 'slide' | 'fade'
  presentationStyle="pageSheet" // iOS only
>
  <Content />
</Modal>

Nested Modals

Handle multiple modal layers:

function NestedModalsExample() {
  const [showFirst, setShowFirst] = useState(false);
  const [showSecond, setShowSecond] = useState(false);
  
  return (
    <>
      <Modal
        visible={showFirst}
        onClose={() => setShowFirst(false)}
        title="First Modal"
      >
        <Button onPress={() => setShowSecond(true)}>
          Open Second Modal
        </Button>
      </Modal>
      
      <Modal
        visible={showSecond}
        onClose={() => setShowSecond(false)}
        title="Second Modal"
        // Higher z-index for proper layering
        style={{ zIndex: 1001 }}
      >
        <Text>This modal is on top</Text>
      </Modal>
    </>
  );
}

Controlled Focus

Manage focus for accessibility:

<Modal
  visible={visible}
  onClose={onClose}
  onShow={() => {
    // Focus first input when modal opens
    firstInputRef.current?.focus();
  }}
>
  <Input
    ref={firstInputRef}
    placeholder="Auto-focused input"
  />
</Modal>

Props API

PropTypeDefaultDescription
visiblebooleanrequiredControls modal visibility
onClose() => voidrequiredCalled when modal should close
titlestring-Modal header title
childrenReactNode-Modal content
primaryButtonButtonProps-Primary action button
secondaryButtonButtonProps-Secondary action button
fullScreenbooleanfalseFull screen presentation
animationType'none' | 'slide' | 'fade''fade'Animation style
backdropOpacitynumber0.5Backdrop darkness (0-1)
onShow() => void-Called when modal appears

Accessibility

Modal pattern includes comprehensive accessibility:

  • Focus Management: Traps focus within modal
  • Keyboard Navigation: Esc key closes modal
  • Screen Reader: Announces modal presence
  • Touch Outside: Closes modal (configurable)
<Modal
  visible={visible}
  onClose={onClose}
  accessibilityLabel="Confirmation dialog"
  accessibilityRole="alert"
  accessibilityViewIsModal={true}
>
  <Text>Accessible modal content</Text>
</Modal>

Common Patterns

Confirmation Dialog

function ConfirmationModal({ visible, onConfirm, onCancel, message }) {
  return (
    <Modal
      visible={visible}
      onClose={onCancel}
      title="Confirm Action"
      primaryButton={{
        children: 'Confirm',
        variant: 'primary',
        onPress: () => {
          onConfirm();
          onCancel(); // Close modal
        }
      }}
      secondaryButton={{
        children: 'Cancel',
        variant: 'ghost',
        onPress: onCancel
      }}
    >
      <Text>{message}</Text>
    </Modal>
  );
}

Loading Modal

function LoadingModal({ visible, message }) {
  return (
    <Modal
      visible={visible}
      onClose={() => {}} // Prevent closing
      backdropOpacity={0.8}
    >
      <View style={styles.loadingContent}>
        <ActivityIndicator size="large" />
        <Text style={styles.loadingText}>
          {message || 'Loading...'}
        </Text>
      </View>
    </Modal>
  );
}

Form Submission

function FormModal({ visible, onClose, onSubmit }) {
  const [formData, setFormData] = useState({});
  const [loading, setLoading] = useState(false);
  
  const handleSubmit = async () => {
    setLoading(true);
    try {
      await onSubmit(formData);
      onClose();
    } catch (error) {
      // Handle error
    } finally {
      setLoading(false);
    }
  };
  
  return (
    <Modal
      visible={visible}
      onClose={onClose}
      title="Submit Form"
      primaryButton={{
        children: 'Submit',
        loading,
        onPress: handleSubmit
      }}
    >
      <Form data={formData} onChange={setFormData} />
    </Modal>
  );
}

Testing

Example tests for Modal pattern:

import { render, fireEvent } from '@testing-library/react-native';
import { Modal } from '@/ui/patterns/Modal';
 
describe('Modal', () => {
  it('renders when visible', () => {
    const { getByText } = render(
      <Modal visible={true} onClose={jest.fn()} title="Test Modal">
        <Text>Modal Content</Text>
      </Modal>
    );
    
    expect(getByText('Test Modal')).toBeTruthy();
    expect(getByText('Modal Content')).toBeTruthy();
  });
  
  it('calls onClose when backdrop pressed', () => {
    const onClose = jest.fn();
    const { getByTestId } = render(
      <Modal 
        visible={true} 
        onClose={onClose}
        testID="modal"
      >
        <Text>Content</Text>
      </Modal>
    );
    
    fireEvent.press(getByTestId('modal-backdrop'));
    expect(onClose).toHaveBeenCalled();
  });
});

Need help? Check the patterns-guide or ask in #ui-development channel.

On this page