UI Components
Modal.
Centred dialog on a blurred backdrop, built on native <dialog> for focus trapping and Escape. Backdrop click closes.
OverlaysComponent1 dependency Original, MIT
Using Claude Code? Give it the agent prompt and skip these steps.
-
Import the foundation once
foundation.css holds the tokens every component reads.
--- // src/layouts/Layout.astro import "../styles/foundation.css"; --- -
Use it
--- import Modal from "../components/viewership/modal/Modal.astro"; --- <Modal />
Props 8
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | · | Dialog heading, also its accessible name. |
description | string | · | Line under the title (aria-describedby). |
id | string | · | Dialog id. Any element with data-vs-modal-open set to it opens the dialog. |
triggerLabel | string | · | Renders a primary button that opens the dialog. |
size | "sm" | "md" | "sm" | Dialog width: 440px or 600px. |
slot: default | slot | · | Body content. |
slot: actions | slot | · | Submit buttons that close the dialog. Their value becomes returnValue. |
slot: trigger | slot | · | Custom trigger. Give it data-vs-modal-open with the dialog id. |
Events
| Event | Detail | When |
|---|---|---|
vs-close | CustomEvent<{ value: string }> | Fires on close with the returnValue: the action button's value, "dismiss" from the X or backdrop, empty after Escape. |
Source 1 file
---
/**
* Modal: a centred dialog on a blurred backdrop, built on the native
* <dialog> element, so focus trapping, Escape and the top layer come from the
* browser. Clicking the backdrop also closes it.
*
* Open it with the built-in trigger (`triggerLabel` or the trigger slot), or
* from anywhere with an element that has `data-vs-modal-open="<id>"`.
* Buttons in the actions slot close the dialog; their `value` becomes the
* dialog's returnValue and is sent with the vs-close event.
*
* @example
* <Modal id="approve" triggerLabel="Review draft" title="Approve this draft?" description="It goes out on Thursday.">
* <Button slot="actions" type="submit" value="cancel" variant="ghost">Cancel</Button>
* <Button slot="actions" type="submit" value="approve" variant="primary">Approve</Button>
* </Modal>
*/
import Button from "../button/Button.astro";
interface Props {
title: string;
description?: string;
/** Dialog id, used by external `data-vs-modal-open` triggers. */
id?: string;
/** Label for the built-in trigger button. Omit to render no trigger. */
triggerLabel?: string;
size?: "sm" | "md";
class?: string;
}
const {
title,
description,
id = `vs-modal-${Math.random().toString(36).slice(2, 8)}`,
triggerLabel,
size = "sm",
class: className,
} = Astro.props;
const hasTrigger = triggerLabel || Astro.slots.has("trigger");
---
<div class:list={["vs-modal", className]}>
{
hasTrigger && (
<div class="vs-modal__trigger">
<slot name="trigger">
<Button variant="primary" data-vs-modal-open={id}>{triggerLabel}</Button>
</slot>
</div>
)
}
<dialog class:list={["vs-modal__dialog", `is-${size}`]} id={id} aria-labelledby={`${id}-title`} aria-describedby={description ? `${id}-desc` : undefined}>
<div class="vs-modal__panel">
<form method="dialog" class="vs-modal__close-form">
<button class="vs-modal__close" value="dismiss" aria-label="Close">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</form>
<h2 class="vs-modal__title" id={`${id}-title`}>{title}</h2>
{description && <p class="vs-modal__desc" id={`${id}-desc`}>{description}</p>}
{
Astro.slots.has("default") && (
<div class="vs-modal__body">
<slot />
</div>
)
}
{
Astro.slots.has("actions") && (
<form method="dialog" class="vs-modal__actions">
<slot name="actions" />
</form>
)
}
</div>
</dialog>
</div>
<style>
.vs-modal {
display: contents;
}
.vs-modal__dialog {
/* Explicit centring: CSS resets often zero the UA's auto margin. */
position: fixed;
inset: 0;
margin: auto;
height: fit-content;
width: min(calc(100% - 32px), 440px);
max-height: calc(100dvh - 32px);
padding: 0;
border: none;
border-radius: calc(var(--vs-radius-window) + 6px);
background: var(--vs-surface);
color: var(--vs-ink);
font-family: var(--vs-font-sans);
font-stretch: var(--vs-font-stretch);
box-shadow: var(--vs-pop-shadow, var(--vs-shadow-overlay));
overflow: auto;
}
.vs-modal__dialog.is-md {
width: min(calc(100% - 32px), 600px);
}
.vs-modal__dialog[open] {
animation: vs-modal-in var(--vs-duration) var(--vs-ease-out);
}
.vs-modal__dialog::backdrop {
background: color-mix(in srgb, var(--vs-ink) 28%, transparent);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
animation: vs-fade-in var(--vs-duration) var(--vs-ease-out);
}
.vs-modal__panel {
position: relative;
padding: 28px;
}
.vs-modal__close-form {
position: absolute;
top: 14px;
right: 14px;
margin: 0;
}
.vs-modal__close {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: none;
border-radius: var(--vs-radius-pill);
background: transparent;
color: var(--vs-ink-3);
cursor: pointer;
transition:
background-color var(--vs-duration-fast) ease-out,
color var(--vs-duration-fast) ease-out;
}
.vs-modal__close:hover {
background: var(--vs-hover);
color: var(--vs-ink);
}
.vs-modal__close svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
}
.vs-modal__close:focus-visible {
outline: none;
box-shadow: var(--vs-focus-ring);
}
.vs-modal__title {
margin: 0;
padding-right: 32px;
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.3;
}
.vs-modal__desc {
margin: 8px 0 0;
font-size: 0.9375rem;
line-height: 1.55;
color: var(--vs-ink-2);
}
.vs-modal__body {
margin-top: 20px;
font-size: 0.9375rem;
line-height: 1.55;
}
.vs-modal__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
margin: 24px 0 0;
}
@keyframes vs-modal-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
to {
opacity: 1;
transform: none;
}
}
</style>
<script>
function wire(dialog: HTMLDialogElement) {
// Backdrop click: the event target is the dialog itself, outside the panel.
dialog.addEventListener("click", (e) => {
if (e.target === dialog) dialog.close("dismiss");
});
dialog.addEventListener("close", () => {
dialog.dispatchEvent(new CustomEvent("vs-close", { detail: { value: dialog.returnValue }, bubbles: true }));
});
}
const boot = () =>
document.querySelectorAll<HTMLDialogElement>(".vs-modal__dialog:not([data-vs-ready])").forEach((d) => {
d.dataset.vsReady = "";
wire(d);
});
// One delegated listener opens any modal by id.
document.addEventListener("click", (e) => {
const t = (e.target as Element | null)?.closest<HTMLElement>("[data-vs-modal-open]");
if (!t) return;
const d = document.getElementById(t.dataset.vsModalOpen!);
if (d instanceof HTMLDialogElement && !d.open) {
d.returnValue = "";
d.showModal();
}
});
boot();
document.addEventListener("astro:page-load", boot);
</script> Original, MIT.