Modal UI Elements
Native <dialog> modal met backdrop blur, slide-in animatie en footer slot. Openen via data-modal-open attribuut.
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