> **Project:** Keystone UI
> **Version:** v1.0.0
> **Page Type:** Documentation / Reference
> **Title:** Dialog
> **URL:** https://keystone.oxypteros.com/docs/components/dialog/
> **Created:** 2026-07-17T00:00:00+01:00
> **Updated:** 2026-07-17T00:00:00+01:00
> **Technology:** Hugo ^0.164.0, Tailwind 4.3, AlpineJs/CSP (Focus Plugin)
> **Component Type:** Interactive / Alpine.js
> **Language:** en

---

# Dialog

The `dialog` component displays content in a focused modal overlay. It follows the [WAI-ARIA Dialog Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/).

## AI Rules & Instructions

When generating or editing code involving this component, strictly adhere to these rules:

### 1. Scroll Lock: `inert` Does NOT Prevent Scrolling

**This is the most common mistake.** The `inert` attribute only blocks focus and interaction, it does **not** prevent the page from scrolling. The dialog also needs:

- `document.body.style.overflow = 'hidden'` on open (restored on close)


### 2. The `#ks-main-hook` Element MUST Exist

The inert attribute is applied to `document.querySelector('#ks-main-hook')`. This element **must** exist in the layout template. 

If you create a custom layout that uses the dialog, you **must** include `<main id="ks-main-hook">` (or update the selector in `dialog.js`). The `?.` optional chaining means it will silently fail if missing, the dialog will open but inert won't be applied.

### 3. Partial Wrapping: `{{- with partial "ui/dialog" ... }}` ... `{{- end }}`

The dialog partial is **always** called inside a `{{- with ... }}` block. The dialog body content goes **between** the `with` opening and the closing `{{- end }}`:

```html
{{- with partial "ui/dialog" (dict "trigger" "Open" "title" "My Dialog") }}
  <p>This is the dialog body content.</p>
{{- end }}
```

Using `{{ partial }}` (without `with`) or placing content outside the `with` block will **not work** — the content will not render inside the dialog.

### 4. Content via `.content` Dict vs `inner`

The partial receives its body content from two sources (checked in order):

- `.content` (dict parameter) — when calling the partial programmatically
- `inner .` (Hugo's `inner` function) — when called from a shortcode or with inner template content

If `.content` is provided, it is used and `inner .` is ignored.

### 5. `x-teleport="body"` — DOM Position

The dialog template uses `<template x-teleport="body">`, which moves the dialog's DOM nodes to the end of `<body>` at runtime. This means:

- CSS selectors that rely on parent-child DOM relationships (e.g. `.parent > .dialog`) will **not** match.
- Styles that depend on the dialog being inside a specific container will break.
- The dialog is always rendered as a direct child of `<body>`.

### 6. Required Alpine Plugin: `@alpinejs/focus`

The `x-trap.noscroll` directive comes from the `@alpinejs/focus` plugin. This **must** be registered in the main JS bundle:

```javascript
import focus from '@alpinejs/focus';
Alpine.plugin(focus);
```

Without this plugin, `x-trap.noscroll` will silently do nothing — no focus trap and no scroll lock.

### 7. Shortcode vs Partial Differences

| Aspect | `ui/dialog` partial | `ui/dialog` shortcode |
|---|---|---|
| Call syntax | `{{ partial "ui/dialog" (dict ...) }}` | `{{</* ui/dialog trigger="..." */>}}...{{</* /ui/dialog */>}}` |
| Content passes through | `.content` dict param or `inner` via `with` | `.Inner` → passed as `.content` |
| Placeholder content | Between `{{- with ... }}` and `{{- end }}` | Between opening and closing shortcode tags |

The shortcode wrapper (`_shortcodes/ui/dialog.html`) calls the same partial internally. The shortcode is for Markdown content; the partial is for templates.

### 8. Escape Handler Guard

The Escape key handler is:
```
@keydown.escape.window="open && closeDialog()"
```

The `open &&` guard prevents `closeDialog()` from firing when the dialog is already closed. Never remove this guard.

### 9. `ks-dialog-footer` is a CSS Class Only

The `ks-dialog-footer` class (defined in `_dialog.css`) is a utility class for arranging action buttons. It is **not** a separate component or partial. It provides:

```css
.ks-dialog-footer {
  @apply flex flex-col-reverse gap-2 sm:flex-row sm:justify-end;
}
```

Buttons inside the footer should use the standard Keystone UI button component `btn` classes (e.g. `btn variant-default size-default`).

### 10. Close Button Icon

The close button uses:
```
{{ partial "ui/icon" (dict "name" "dialog/x") }}
```
or an inline SVG

```
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
```

This renders the close SVG (x). The Keystone UI icon component and SVG `assets/icons/dialog/x.svg` must be available if the user use the first option.

### 11. Dialog is Disabled by Default

The dialog component is **commented out** in the default installation. Users must uncomment the imports in `main.js` and `main.css` to enable it. If the dialog does not work on a user's starter site, check whether they've enabled it.

## Example Usage

### Correct: Partial in Template

```html
{{- with partial "ui/dialog" (dict
  "trigger" "Open Dialog"
  "title" "Edit Profile"
  "description" "Make changes to your profile."
) }}
  <form class="flex flex-col gap-4">
    <div class="flex flex-col gap-2">
      <label for="name" class="text-sm font-medium">Name</label>
      <input id="name" value="John Doe" class="border-border bg-background text-foreground rounded-sm border px-3 py-2 text-sm" />
    </div>
    <div class="ks-dialog-footer">
      <button type="button" @click="closeDialog()" class="btn variant-outline size-default">Cancel</button>
      <button type="button" @click="closeDialog()" class="btn variant-default size-default">Save</button>
    </div>
  </form>
{{- end }}
```

### Correct: Shortcode in Markdown

```markdown
{{</* ui/dialog trigger="Open Dialog" title="Edit Profile" description="Make changes." */>}}

<form class="flex flex-col gap-4">
  <div class="flex flex-col gap-2">
    <label for="name" class="text-sm font-medium">Name</label>
    <input id="name" value="John Doe" />
  </div>
  <div class="ks-dialog-footer">
    <button type="button" @click="closeDialog()" class="btn variant-outline size-default">Cancel</button>
    <button type="button" @click="closeDialog()" class="btn variant-default size-default">Save</button>
  </div>
</form>

{{</* /ui/dialog */>}}
```

### Incorrect: Missing `with` Wrapper

```html
<!-- WRONG: Content will NOT render inside the dialog -->
{{ partial "ui/dialog" (dict "trigger" "Open" "title" "My Dialog") }}
<p>This content is outside the dialog.</p>
```

### Incorrect: Missing `#ks-main-hook` in Custom Layout

```html
<!-- WRONG: inert won't be applied, focus will leak to background -->
<main>
  {{- with partial "ui/dialog" ... }}{{- end }}
</main>
```

## Dictionary Reference

### `ui/dialog` Partial Parameters

- `trigger` (string, optional): Label for the open button. Default: `"Open Dialog"`.
- `title` (string, optional): Dialog heading (`<h2>`). When set, `aria-labelledby` auto-links to it.
- `description` (string, optional): Supplementary text below the title.
- `open` (bool, optional): Initial open state. Default: `false`.
- `content` (string/html, optional): Body content. When omitted, `inner .` is used.

## Source Code (Reference)

### JS Logic (`assets/js/modules/dialog.js`)

```javascript
export default function dialogModule(Alpine) {
  Alpine.data('ksDialog', (config = {}) => ({
    open: config.open || false,

    openDialog() {
      this.open = true;
      document.querySelector('#ks-main-hook')?.setAttribute('inert', '');
      document.body.style.overflow = 'hidden';
    },
    closeDialog() {
      this.open = false;
      document.querySelector('#ks-main-hook')?.removeAttribute('inert');
      document.body.style.overflow = '';
    },
  }));
}
```

### Hugo Partial (`layouts/_partials/ui/dialog.html`)

```hugo
{{- $triggerLabel := .trigger | default "Open Dialog" -}}
{{- $title := .title | default "" -}}
{{- $description := .description | default "" -}}
{{- $open := .open | default false -}}

{{- $bodyContent := "" -}}
{{- if .content }}
  {{- $bodyContent = .content | strings.TrimSpace -}}
{{- else }}
  {{- $bodyContent = inner . | strings.TrimSpace -}}
{{- end -}}

<div x-data="ksDialog({ open: {{ $open }} })" x-id="['ks-dialog-title', 'ks-dialog-description']">
  <button type="button" @click="openDialog()" class="btn variant-default size-default">
    {{- $triggerLabel -}}
  </button>

  <template x-teleport="body">
    <div
      x-show="open"
      x-cloak
      x-trap.noscroll="open"
      @keydown.escape.window="open && closeDialog()"
      class="ks-dialog-container"
    >
      <div @click="closeDialog()" class="ks-dialog-overlay" aria-hidden="true"></div>

      <div
        tabindex="-1"
        role="dialog"
        aria-modal="true"
        {{- with $title }}
          :aria-labelledby="$id('ks-dialog-title')"
        {{- end }}
        class="ks-dialog-content"
      >
        <div class="ks-dialog-header">
          {{- if $title }}
            <h2 :id="$id('ks-dialog-title')" class="ks-dialog-title">{{ $title }}</h2>
          {{- end }}
          {{- if $description }}
            <p :id="$id('ks-dialog-description')" class="ks-dialog-description">{{ $description }}</p>
          {{- end }}
        </div>

        <div class="mt-4">
          {{- $bodyContent | safeHTML -}}
        </div>

        <button
          type="button"
          @click="closeDialog()"
          class="ks-dialog-close btn variant-ghost size-icon"
          aria-label="Close"
        >
          {{ partial "ui/icon" (dict "name" "dialog/x") -}}
        </button>
      </div>
    </div>
  </template>
</div>
```

### Hugo Shortcode (`layouts/_shortcodes/ui/dialog.html`)

```hugo
{{- $path := "_partials/ui/dialog.html" -}}
{{- $partial := "ui/dialog" }}
{{- if templates.Exists $path -}}
  {{- partial $partial (dict
    "trigger"     (.Get "trigger")
    "title"       (.Get "title")
    "description" (.Get "description")
    "open"        (.Get "open")
    "content"     .Inner
    )
  -}}
{{- else -}}
  {{- $errorMsg := printf "Keystone UI: Missing partial `%s`." $path -}}
  {{- if hugo.IsProduction -}}
    {{- errorf $errorMsg -}}
  {{- else }}
    {{- warnf $errorMsg -}}
  {{- end -}}
{{- end -}}
```

### CSS (`assets/css/components/_dialog.css`)

```css
@layer components {
  .ks-dialog-container {
    @apply fixed inset-0 z-90 flex items-center justify-center;
  }
  .ks-dialog-overlay {
    @apply absolute inset-0 bg-black/80 supports-backdrop-filter:backdrop-blur-xs;
  }
  .ks-dialog-content {
    @apply relative z-10;
    @apply w-full max-w-lg;
    @apply bg-popover text-popover-foreground;
    @apply border-border rounded-lg border shadow-lg;
    @apply p-6;
  }
  .ks-dialog-header {
    @apply flex flex-col gap-1.5 text-center sm:text-left;
  }
  .ks-dialog-footer {
    @apply flex flex-col-reverse gap-2 sm:flex-row sm:justify-end;
  }
  .ks-dialog-title {
    @apply text-lg leading-none font-semibold tracking-tight;
  }
  .ks-dialog-description {
    @apply text-muted-foreground text-sm;
  }
  .ks-dialog-close {
    @apply absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100;
    @apply focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none;
    @apply size-4;
    @apply [&_svg]:size-4;
  }
}
```