# Modal

> Centred dialog on a blurred backdrop, built on native <dialog> for focus trapping and Escape. Backdrop click closes.

- **Slug:** `modal`
- **Kind:** component · **Category:** Overlays
- **Source:** original to Viewership UI
- **Docs page:** https://viewership.ai/design/components/modal
- **All files in one bundle:** https://viewership.ai/design/components/modal.bundle.txt
- **Structured JSON:** https://viewership.ai/design/components/modal.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/design/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy this file into `src/components/viewership/modal/`:

   - `Modal.astro` (raw: https://viewership.ai/design/raw/modal/Modal.astro.txt)

3. Copy its registry dependencies, each into its own sibling folder under `src/components/viewership/`:

   - `button`: `Button.astro` (docs: https://viewership.ai/design/components/button.md)

npm packages: none.

## Usage

```astro
---
import Modal from "../components/viewership/modal/Modal.astro";
---

<Modal />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import Modal from "./Modal.astro";
import Button from "../button/Button.astro";
---

<div class="stage">
  <Modal
    triggerLabel="Review draft"
    title="Send this draft for approval?"
    description="The spring trip email goes to the brand lead. They can approve it or leave notes."
  >
    <Button slot="actions" type="submit" value="cancel" variant="ghost">Not yet</Button>
    <Button slot="actions" type="submit" value="send" variant="primary">Send for approval</Button>
  </Modal>
</div>

<style>
  .stage {
    display: grid;
    place-items: center;
    min-height: 220px;
    padding: 32px 20px;
    border-radius: var(--vs-radius-window);
    background: var(--vs-canvas);
  }
</style>
```

## Props

| 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. |
| `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

### `src/components/viewership/modal/Modal.astro`

```astro
---
/**
 * 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>
```
