Modal

Material Design 3 modal dialog component with animations, positions, and customizable options

Modal

Modals are overlay dialogs that focus user attention on a specific task or message. @duskmoon-dev/core provides a complete set of Material Design 3-inspired modal variants with smooth animations and flexible positioning.

Basic Usage

A basic modal consists of a backdrop and modal container with header, body, and footer sections.

Basic Modal

Sizes

Modals come in five different sizes to accommodate various content needs.

Small Modal

For simple confirmations or short messages.

Small Modal

Medium Modal (Default)

The default size, suitable for most use cases.

Medium Modal

Large Modal

For more complex content or forms.

Large Modal

Extra Large Modal

For extensive content or detailed forms.

Extra Large Modal

Full Screen Modal

Takes up almost the entire viewport (95vw × 95vh).

Full Screen Modal

Positions

Control where the modal appears on the screen.

Center Position (Default)

Modal appears in the center of the viewport.

Center Position Modal

Top Position

Modal appears at the top of the viewport.

Top Position Modal

Bottom Position

Modal appears at the bottom of the viewport.

Bottom Position Modal

Animations

Different animation effects for opening/closing modals.

Scale Animation (Default)

The default scaling effect.

Scale Animation

Slide Up Animation

Modal slides up from the bottom.

Slide Up Animation

Slide Down Animation

Modal slides down from the top.

Slide Down Animation

Zoom Animation

Modal zooms in with emphasis.

Zoom Animation

Backdrop Variants

Customize the appearance of the backdrop overlay.

Default Backdrop

Standard dark semi-transparent backdrop.

Default Backdrop

Light Backdrop

Light semi-transparent backdrop.

Light Backdrop

Blur Backdrop

Backdrop with blur effect.

Blur Backdrop

No Backdrop

Modal without backdrop (be cautious with accessibility).

No Backdrop

Content Variants

Scrollable Body

For long content that needs scrolling.

Scrollable Modal

No Padding

Remove default padding for custom layouts.

No Padding Modal

Centered Content

Center content vertically and horizontally in the body.

Centered Content Modal

JavaScript Control

Modals can be controlled with JavaScript by toggling classes.

JavaScript Controlled Modal

Best Practices

Accessibility

  • Always include a close button with proper aria-label
  • Add focus trap to keep keyboard navigation within the modal
  • Return focus to trigger element when modal closes
  • Use role="dialog" and aria-modal="true" attributes
  • Provide descriptive titles with proper heading hierarchy

Accessible Modal

Body Scroll Lock

Prevent background scrolling when modal is open by adding modal-open class to body:

// Open modal
document.body.classList.add('modal-open');

// Close modal
document.body.classList.remove('modal-open');

Be cautious when stacking multiple modals. Consider using a single modal with dynamic content instead.

Mobile Responsiveness

On mobile devices, modals automatically adjust to 95% viewport width. Consider using bottom-positioned modals for mobile-first designs.

Framework Examples

React

import { useState, useEffect } from 'react';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
  size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
  position?: 'center' | 'top' | 'bottom';
  animation?: 'scale' | 'slide-up' | 'slide-down' | 'zoom';
}

export function Modal({
  isOpen,
  onClose,
  title,
  children,
  size = 'md',
  position = 'center',
  animation = 'scale'
}: ModalProps) {
  useEffect(() => {
    if (isOpen) {
      document.body.classList.add('modal-open');
    } else {
      document.body.classList.remove('modal-open');
    }
    return () => document.body.classList.remove('modal-open');
  }, [isOpen]);

  useEffect(() => {
    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === 'Escape' && isOpen) {
        onClose();
      }
    };
    document.addEventListener('keydown', handleEscape);
    return () => document.removeEventListener('keydown', handleEscape);
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  const modalClasses = `modal modal-${size} ${animation !== 'scale' ? `modal-${animation}` : ''}`;
  const backdropClasses = `modal-backdrop modal-backdrop-${position} modal-open`;

  return (
    <div className={backdropClasses} onClick={(e) => e.target === e.currentTarget && onClose()}>
      <div className={modalClasses}>
        <div className="modal-header">
          <h2 className="modal-title">{title}</h2>
          <button className="modal-close" onClick={onClose} aria-label="Close">
            &times;
          </button>
        </div>
        <div className="modal-body">
          {children}
        </div>
      </div>
    </div>
  );
}

// Usage
function App() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <button className="btn btn-primary" onClick={() => setIsOpen(true)}>
        Open Modal
      </button>

      <Modal
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        title="My Modal"
        size="lg"
        animation="slide-up"
      >
        <p>Modal content goes here.</p>
      </Modal>
    </>
  );
}

Vue

<template>
  <Teleport to="body">
    <div
      v-if="isOpen"
      :class="backdropClasses"
      @click="handleBackdropClick"
      role="dialog"
      aria-modal="true"
      :aria-labelledby="titleId"
    >
      <div :class="modalClasses">
        <div class="modal-header">
          <h2 :id="titleId" class="modal-title">{{ title }}</h2>
          <button class="modal-close" @click="close" aria-label="Close">
            &times;
          </button>
        </div>
        <div class="modal-body">
          <slot />
        </div>
        <div v-if="$slots.footer" class="modal-footer">
          <slot name="footer" />
        </div>
      </div>
    </div>
  </Teleport>
</template>

<script setup>
import { computed, watch, onMounted, onUnmounted } from 'vue';

const props = defineProps({
  isOpen: {
    type: Boolean,
    default: false
  },
  title: {
    type: String,
    required: true
  },
  size: {
    type: String,
    default: 'md',
    validator: (value) => ['sm', 'md', 'lg', 'xl', 'full'].includes(value)
  },
  position: {
    type: String,
    default: 'center',
    validator: (value) => ['center', 'top', 'bottom'].includes(value)
  },
  animation: {
    type: String,
    default: 'scale',
    validator: (value) => ['scale', 'slide-up', 'slide-down', 'zoom'].includes(value)
  }
});

const emit = defineEmits(['close']);

const titleId = `modal-title-${Math.random().toString(36).substr(2, 9)}`;

const modalClasses = computed(() => {
  const classes = ['modal', `modal-${props.size}`];
  if (props.animation !== 'scale') {
    classes.push(`modal-${props.animation}`);
  }
  return classes.join(' ');
});

const backdropClasses = computed(() =>
  `modal-backdrop modal-backdrop-${props.position} ${props.isOpen ? 'modal-open' : ''}`
);

const close = () => emit('close');

const handleBackdropClick = (e) => {
  if (e.target === e.currentTarget) {
    close();
  }
};

const handleEscape = (e) => {
  if (e.key === 'Escape' && props.isOpen) {
    close();
  }
};

watch(() => props.isOpen, (newValue) => {
  if (newValue) {
    document.body.classList.add('modal-open');
  } else {
    document.body.classList.remove('modal-open');
  }
});

onMounted(() => {
  document.addEventListener('keydown', handleEscape);
});

onUnmounted(() => {
  document.removeEventListener('keydown', handleEscape);
  document.body.classList.remove('modal-open');
});
</script>

<!-- Usage -->
<template>
  <button class="btn btn-primary" @click="isOpen = true">
    Open Modal
  </button>

  <Modal
    :is-open="isOpen"
    title="My Modal"
    size="lg"
    animation="slide-up"
    @close="isOpen = false"
  >
    <p>Modal content goes here.</p>

    <template #footer>
      <button class="btn btn-text" @click="isOpen = false">Cancel</button>
      <button class="btn btn-primary" @click="handleConfirm">Confirm</button>
    </template>
  </Modal>
</template>

<script setup>
import { ref } from 'vue';

const isOpen = ref(false);

const handleConfirm = () => {
  // Handle confirmation
  isOpen.value = false;
};
</script>

API Reference

Backdrop Classes

ClassDescription
.modal-backdropBase backdrop overlay (required)
.modal-openOpens the modal (shows backdrop and modal)
.modal-backdrop-centerCenters modal in viewport (default)
.modal-backdrop-topPositions modal at top
.modal-backdrop-bottomPositions modal at bottom
.modal-backdrop-lightLight colored backdrop
.modal-backdrop-blurBackdrop with blur effect
.modal-no-backdropRemoves backdrop background
ClassDescription
.modalBase modal container (required)
.modal-smSmall modal (20rem width)
.modal-mdMedium modal (32rem width, default)
.modal-lgLarge modal (48rem width)
.modal-xlExtra large modal (64rem width)
.modal-fullFull screen modal (95vw × 95vh)

Animation Classes

ClassDescription
DefaultScale animation (no extra class needed)
.modal-slide-upSlides up from bottom
.modal-slide-downSlides down from top
.modal-zoomZoom in animation

Content Classes

ClassDescription
.modal-headerModal header section
.modal-titleModal title text
.modal-closeClose button
.modal-bodyModal content area
.modal-footerModal footer with actions
.modal-scrollableMakes body scrollable (max-height: 60vh)
.modal-no-paddingRemoves padding from sections
.modal-centeredCenters content in body

Body Class

ClassDescription
body.modal-openPrevents body scroll when modal is open
  • Button - Action buttons for modal footers
  • Card - Similar container component
  • Alert - Inline notifications

See Also