Zoeken...  ⌘K GitHub

Modal UI Elements

Native <dialog> modal met backdrop blur, slide-in animatie en footer slot. Openen via data-modal-open attribuut.

/modal
src/components/ui/Modal.astro
---
/**
 * Modal / Dialog
 * Toegankelijke overlay dialog. Focustrapje, ESC sluiten, backdrop klik.
 * 3 groottes. Triggered via JS: modal.showModal() / modal.close()
 */
interface Props {
  id: string;
  title?: string;
  size?: 'sm' | 'md' | 'lg' | 'full';
  closeLabel?: string;
}

const {
  id,
  title,
  size = 'md',
  closeLabel = 'Sluiten',
} = Astro.props;
---

<dialog
  class:list={['modal', `modal--${size}`]}
  id={id}
  data-component="modal"
  aria-labelledby={title ? `${id}-title` : undefined}
>
  <div class="modal__inner">
    <!-- Header -->
    {title && (
      <div class="modal__header">
        <h2 class="modal__title" id={`${id}-title`}>{title}</h2>
        <button class="modal__close" aria-label={closeLabel} data-close>
          <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
            <path d="M4 4l10 10M14 4L4 14" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
          </svg>
        </button>
      </div>
    )}
    {!title && (
      <button class="modal__close modal__close--floating" aria-label={closeLabel} data-close>
        <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
          <path d="M4 4l10 10M14 4L4 14" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
        </svg>
      </button>
    )}

    <!-- Content -->
    <div class="modal__body">
      <slot />
    </div>

    <!-- Footer (optional slot) -->
    {Astro.slots.has('footer') && (
      <div class="modal__footer">
        <slot name="footer" />
      </div>
    )}
  </div>
</dialog>

<script>
  document.querySelectorAll<HTMLDialogElement>('[data-component="modal"]').forEach(modal => {
    // Close button
    modal.querySelectorAll('[data-close]').forEach(btn => {
      btn.addEventListener('click', () => modal.close());
    });

    // Backdrop click
    modal.addEventListener('click', (e) => {
      const rect = modal.getBoundingClientRect();
      if (
        e.clientX < rect.left || e.clientX > rect.right ||
        e.clientY < rect.top  || e.clientY > rect.bottom
      ) {
        modal.close();
      }
    });

    // Animate in/out
    modal.addEventListener('close', () => {
      modal.classList.remove('modal--open');
    });
  });

  // Global opener helper: data-modal-open="modal-id"
  document.querySelectorAll<HTMLElement>('[data-modal-open]').forEach(trigger => {
    trigger.addEventListener('click', () => {
      const id = trigger.dataset.modalOpen!;
      const modal = document.getElementById(id) as HTMLDialogElement | null;
      if (modal) {
        modal.showModal();
        modal.classList.add('modal--open');
      }
    });
  });
</script>

<style>
  /* Native <dialog> reset */
  .modal {
    border: none;
    border-radius: 1rem;
    padding: 0;
    background: var(--color-bg, #fff);
    box-shadow: 0 24px 80px rgba(0,0,0,0.18);
    max-height: 90vh;
    overflow: hidden;
    opacity: 0;
    transform: scale(0.96) translateY(8px);
    transition: opacity 0.25s cubic-bezier(0.22,1,0.36,1), transform 0.25s cubic-bezier(0.22,1,0.36,1);
  }

  /* Sizes */
  .modal--sm { width: min(440px, 90vw); }
  .modal--md { width: min(600px, 90vw); }
  .modal--lg { width: min(800px, 90vw); }
  .modal--full { width: 90vw; max-height: 90vh; }

  /* Backdrop */
  .modal::backdrop {
    background: rgba(0,0,0,0.5);
    backdrop-filter: blur(4px);
    -webkit-backdrop-filter: blur(4px);
    opacity: 0;
    transition: opacity 0.25s;
  }

  .modal[open] {
    opacity: 1;
    transform: scale(1) translateY(0);
  }

  .modal[open]::backdrop { opacity: 1; }

  /* Inner */
  .modal__inner {
    display: flex;
    flex-direction: column;
    max-height: 90vh;
    position: relative;
  }

  /* Header */
  .modal__header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 1.5rem 1.75rem;
    border-bottom: 1px solid rgba(0,0,0,0.07);
    flex-shrink: 0;
  }

  .modal__title {
    font-size: 1.0625rem;
    font-weight: 700;
    color: var(--color-primary, #0a0a0a);
    letter-spacing: -0.02em;
    margin: 0;
  }

  .modal__close {
    width: 32px;
    height: 32px;
    border-radius: 50%;
    border: none;
    background: rgba(0,0,0,0.06);
    color: var(--color-primary, #0a0a0a);
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    transition: background 0.15s;
  }

  .modal__close:hover { background: rgba(0,0,0,0.1); }

  .modal__close--floating {
    position: absolute;
    top: 1rem;
    right: 1rem;
    z-index: 1;
  }

  /* Body */
  .modal__body {
    padding: 1.75rem;
    overflow-y: auto;
    flex: 1;
    overscroll-behavior: contain;
  }

  /* Footer */
  .modal__footer {
    padding: 1.25rem 1.75rem;
    border-top: 1px solid rgba(0,0,0,0.07);
    display: flex;
    gap: 0.75rem;
    justify-content: flex-end;
    flex-shrink: 0;
  }
</style>

Props

Prop Type Default Beschrijving
id * string Uniek ID — gebruikt door data-modal-open trigger
title string Modal titel in header
size 'sm' | 'md' | 'lg' | 'xl' 'md' Breedte van de modal

* = verplicht