# Dialog (Lumen Halo): prompt.md (v1.0.0)

- id: `lumen-dialog` · version 1.0.0 · component · free
- category: Overlays
- build: Base UI (this item also ships a Radix build)
- install (this build): `npx shadcn@latest add https://beautiful-ui.dev/r/lumen-dialog.json`
- npm dependencies: @base-ui/react@^1
- registry dependencies: utils, @beautiful-ui/lumen-button, https://beautiful-ui.dev/r/lumen-foundation.json
- docs: https://beautiful-ui.dev/components/lumen-dialog
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

A panel of smoked glass that rises into place while the page behind it dims and softens, and sounds once as it opens and closes. shadcn's Dialog, cut from Lumen glass.

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: `@base-ui/react@^1` (Base UI build); `radix-ui@^1` (Radix build).
- Files: `components/ui/lumen/dialog.tsx`; shared code: `lib/beautiful-ui/lumen/portal.ts`, `lib/beautiful-ui/lumen/glyphs.tsx`, `lib/beautiful-ui/lumen/sound.ts`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `lumen-button`, `lumen-foundation`.
- Builds: separate Base UI and Radix files. Install the one that matches the project (see Install): a free item's bare URL installs the Base UI build of it and its dependencies.
- Exports to keep: `Dialog`, `DialogTrigger`, `DialogContent`, `DialogHeader / DialogTitle / DialogDescription`, `DialogFooter`, `DialogClose`, and every exported type.
- CSS: the install merges this item's rules (the registry `css` field) into your global stylesheet, in `@layer components`, and adds the lumen foundation (tokens, keyframes, motion levels) once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Tokens: retheme with the `--lumen-*` custom properties (`--lumen-accent`, `--lumen-accent-text`, `--lumen-bad`, `--lumen-bad-text`, `--lumen-focus`, `--lumen-good`, `--lumen-good-text`, `--lumen-hairline`, `--lumen-ink`, `--lumen-muted-ink`, `--lumen-series-1`, `--lumen-series-2`, `--lumen-series-3`, `--lumen-series-4`, `--lumen-series-5`, `--lumen-series-6`, `--lumen-warn`, `--lumen-warn-text`); this item's CSS also reads `--lumen-font-sans`, `--lumen-radius-k`. Never add Tailwind colour classes inside the component.

```tsx
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/lumen/dialog";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `Dialog` | `dialog` | Root: open state. |
| `DialogTrigger` | `dialog-trigger` | Opens it. |
| `DialogContent` | `dialog-content` | The glass panel, portalled, with the overlay. |
| `DialogHeader / DialogTitle / DialogDescription` | `dialog-header` | The heading. |
| `DialogFooter` | `dialog-footer` | The shelf for actions. |
| `DialogClose` | `dialog-close` | Closes it. |

Style a part with `[data-slot="<slot>"]` selectors or its `className`; keep the attributes when editing.

## Sound
- Keep every `data-slot` and `data-sound` attribute: the sound layer reads them.
- Installing this item adds no audio. Nothing plays until the app mounts `GlassSoundProvider` once (install: `npx shadcn@latest add https://beautiful-ui.dev/r/glass-sound.json`, import from `@/components/beautiful-ui/glass-sound`); `GlassSoundToggle` is its mute control. Without a provider the audio engine never loads.

## Match the original
- Read `components/ui/lumen/dialog.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when
- dialog, modal, popup, lightbox form, confirm, overlay, shadcn dialog, Lumen
- A short task that needs focus: rename, invite, a small form
- A drop-in for shadcn's Dialog: same parts and showCloseButton

### Not when
- A decision that must be answered: use Alert Dialog
- Long content or navigation: use Sheet

## Mistakes
- Always give it a DialogTitle (visually hidden if you must)
- Don't stack dialogs

## Usage

```tsx
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/lumen/dialog";

<Dialog>
  <DialogTrigger>Rename</DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Rename dashboard</DialogTitle>
      <DialogDescription>Everyone with access sees the new name.</DialogDescription>
    </DialogHeader>
    <DialogFooter showCloseButton />
  </DialogContent>
</Dialog>
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `DialogContent showCloseButton` | `boolean` | `true` | The glass close button, top right. |
| `DialogFooter showCloseButton` | `boolean` | `false` | An outline Close button in the footer. |
| `open / onOpenChange` | `boolean / (open) => void` |  | Controlled or not, as shadcn. |

Full docs: https://beautiful-ui.dev/components/lumen-dialog

## Customising
- Colours: set the `--lumen-*` tokens on `:root`, or on a container with the `lumen-scope` class to retheme one area. Add the `lumen-inherit` class to follow your shadcn palette instead (`--chart-N`, `--destructive`).
- Dark mode follows the `.dark` class on an ancestor (the shadcn and next-themes convention).
- Update later by re-running the install with `--overwrite` (review the diff if you edited it). Changelog: https://beautiful-ui.dev/r/changelog.json

## Keyboard

| Keys | Action |
|---|---|
| Escape | Close |
| Tab | Move within the dialog |

## Motion inventory

| Interaction | What moves |
|---|---|
| Open | Rises 10px and scales in on Lumen's spring; the page dims and blurs |
| Close | A quick fade and sink |

## Accessibility contract (preserve when editing)
- Focus moves in on open, is trapped, and returns to the trigger on close
- The title and description name and describe the dialog
- Escape and the backdrop close it

## Install

Base UI project (a base-* style in components.json):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/lumen-dialog.json
```

Radix project (a radix-*, new-york or default style):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/radix-nova/lumen-dialog.json
```

Or add the `@beautiful-ui` registry to components.json and run `npx shadcn@latest add @beautiful-ui/lumen-dialog`: the CLI picks the build from your style.

## Credits
- Built on shadcn/ui (https://ui.shadcn.com)

## Source (Base UI build)

### components/ui/lumen/dialog.tsx

```tsx
"use client";

/**
 * Dialog (Lumen Halo) v1.0.0 · Beautiful UI
 * Docs: https://beautiful-ui.dev/components/lumen-dialog · Agent prompt: https://beautiful-ui.dev/md/lumen-dialog.md
 * Licensed to the purchaser under the Beautiful UI license: https://beautiful-ui.dev/license
 */

import * as React from "react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "@/lib/utils";
import { useLumenPortal } from "@/lib/beautiful-ui/lumen/portal";
import { buttonVariants } from "@/components/ui/lumen/button";
import { LumenIcon } from "@/lib/beautiful-ui/lumen/glyphs";
import { emitSound } from "@/lib/beautiful-ui/lumen/sound";

/*
 * Lumen Dialog (Base UI build). shadcn's Dialog in Lumen Halo: a panel of smoked glass that rises a
 * few pixels into place on Lumen's spring while the page behind it dims and softens. It plays "open"
 * and "close" once per change, however it opened or closed. Same exports, parts and data-slots as
 * shadcn, including showCloseButton on the content and the footer.
 */

function Dialog({ onOpenChange, ...props }: DialogPrimitive.Root.Props) {
  return (
    <DialogPrimitive.Root
      data-slot="dialog"
      onOpenChange={(open, details) => {
        onOpenChange?.(open, details);
        if (!details.isCanceled) emitSound(document.activeElement, open ? "open" : "close");
      }}
      {...props}
    />
  );
}

function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
  return <DialogPrimitive.Trigger data-slot="dialog-trigger" data-sound="none" {...props} />;
}

function DialogPortal({ container, ...props }: DialogPrimitive.Portal.Props) {
  const scope = useLumenPortal();
  return <DialogPrimitive.Portal data-slot="dialog-portal" container={container ?? scope} {...props} />;
}

function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
  return <DialogPrimitive.Close data-slot="dialog-close" data-sound="none" {...props} />;
}

function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
  return <DialogPrimitive.Backdrop data-slot="dialog-overlay" className={cn(`lumen-dialog-overlay`, className)} {...props} />;
}

function DialogContent({
  className,
  children,
  showCloseButton = true,
  ...props
}: DialogPrimitive.Popup.Props & {
  showCloseButton?: boolean;
}) {
  return (
    <DialogPortal>
      <DialogOverlay />
      <DialogPrimitive.Popup data-slot="dialog-content" className={cn(`lumen-dialog lumen-control`, className)} {...props}>
        {children}
        {showCloseButton && (
          <DialogPrimitive.Close data-slot="dialog-close" data-sound="none" className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), `lumen-dialog-close`)}>
            <LumenIcon name="close" />
            <span className="sr-only">Close</span>
          </DialogPrimitive.Close>
        )}
      </DialogPrimitive.Popup>
    </DialogPortal>
  );
}

function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
  return <div data-slot="dialog-header" className={cn(`lumen-dialog-header`, className)} {...props} />;
}

function DialogFooter({
  className,
  showCloseButton = false,
  children,
  ...props
}: React.ComponentProps<"div"> & {
  showCloseButton?: boolean;
}) {
  return (
    <div data-slot="dialog-footer" className={cn(`lumen-dialog-footer`, className)} {...props}>
      {children}
      {showCloseButton && (
        <DialogPrimitive.Close data-sound="none" className={buttonVariants({ variant: "outline" })}>
          Close
        </DialogPrimitive.Close>
      )}
    </div>
  );
}

function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
  return <DialogPrimitive.Title data-slot="dialog-title" className={cn(`lumen-dialog-title`, className)} {...props} />;
}

function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
  return <DialogPrimitive.Description data-slot="dialog-description" className={cn(`lumen-dialog-description`, className)} {...props} />;
}

export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger };
```

### lib/beautiful-ui/lumen/portal.ts

```tsx
"use client";

import * as React from "react";

/*
 * Where Lumen overlays (dialogs, sheets, menus, popovers, select) portal to. Unset, they portal to
 * document.body, as shadcn's do. Provide an element to keep them inside a themed or scoped part of
 * the page: a preview, a shadow root, an embedded widget.
 *
 *   <LumenPortalContext value={element}>…</LumenPortalContext>
 */
export const LumenPortalContext = React.createContext<HTMLElement | null>(null);

/** The container for an overlay's Portal: the provided element, else undefined (document.body). */
export function useLumenPortal(): HTMLElement | undefined {
  return React.useContext(LumenPortalContext) ?? undefined;
}
```

### lib/beautiful-ui/lumen/glyphs.tsx

```tsx
import type * as React from "react";

/*
 * Lumen's glyphs for its shadcn-built controls: the 4-cell loading ticker, the check that draws
 * itself in, the ! glyph, and the cell-drawn icon set. The same drawings as the glass family.
 */

const SVG = { width: "1.1em", height: "1.1em", viewBox: "0 0 16 16", fill: "none", "aria-hidden": true, focusable: false } as const;
const BOX: React.CSSProperties = { display: "block", flex: "none" };
const ST = { stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", fill: "none" } as const;
const cell = (x: number, y: number, s = 3) => <rect key={`${x}-${y}`} x={x} y={y} width={s} height={s} rx={0.9} fill="currentColor" />;

/** The 4-cell loading ticker in currentColor (.32em cells, .16em gaps). */
export function LumenTicker({ className }: { className?: string }) {
  return (
    <span aria-hidden className={className ? `lumen-tick ${className}` : `lumen-tick`}>
      <span />
      <span />
      <span />
      <span />
    </span>
  );
}

/** The check that draws itself in (stroke-dashoffset 16 → 0). Remount it (key) to replay. */
export function LumenCheckGlyph() {
  return (
    <svg {...SVG} className={`lumen-check`} style={BOX}>
      <path d="M3.5 8.4l2.9 2.9 6.1-6.6" {...ST} strokeWidth={1.9} />
    </svg>
  );
}

/** The ! glyph, cropped tight so its gap to a label matches the other glyphs. */
export function LumenErrorGlyph() {
  return (
    <svg {...SVG} width=".36em" viewBox="6.2 0 3.6 16" style={{ ...BOX, margin: "0 1px 0 -1px" }}>
      <rect x={6.9} y={2.6} width={2.2} height={7} rx={1.1} fill="currentColor" />
      <rect x={6.9} y={11.2} width={2.2} height={2.2} rx={0.8} fill="currentColor" />
    </svg>
  );
}

export const LUMEN_ICON_NAMES = ["plus", "grid", "more", "search", "filter", "copy", "arrow", "trash", "close", "check"] as const;
export type LumenIconName = (typeof LUMEN_ICON_NAMES)[number];
export const isLumenIconName = (v: unknown): v is LumenIconName => typeof v === "string" && (LUMEN_ICON_NAMES as readonly string[]).includes(v);

/** One icon from the Lumen set, 1.1em square in currentColor: plus, grid and more are cells; the rest are 1.6px strokes. */
export function LumenIcon({ name }: { name: LumenIconName }) {
  const p = { ...SVG, style: BOX };
  switch (name) {
    case "plus":
      return <svg {...p}>{[cell(6.5, 2.5), cell(2.5, 6.5), cell(6.5, 6.5), cell(10.5, 6.5), cell(6.5, 10.5)]}</svg>;
    case "more":
      return <svg {...p}>{[cell(2, 6.5), cell(6.5, 6.5), cell(11, 6.5)]}</svg>;
    case "grid":
      return <svg {...p}>{[cell(3, 3, 4), cell(9, 3, 4), cell(3, 9, 4), cell(9, 9, 4)]}</svg>;
    case "search":
      return (
        <svg {...p}>
          <circle cx={7} cy={7} r={4.25} {...ST} />
          <path d="M10.3 10.3L13.5 13.5" {...ST} />
        </svg>
      );
    case "copy":
      return (
        <svg {...p}>
          <rect x={5.5} y={5.5} width={8} height={8} rx={2} {...ST} />
          <path d="M10.5 5.5V4a1.5 1.5 0 0 0-1.5-1.5H4A1.5 1.5 0 0 0 2.5 4v5A1.5 1.5 0 0 0 4 10.5h1.5" {...ST} />
        </svg>
      );
    case "trash":
      return (
        <svg {...p}>
          <path d="M2.8 4.5h10.4M6.4 4.5V3h3.2v1.5M4.3 4.5l.6 8.5h6.2l.6-8.5" {...ST} />
        </svg>
      );
    case "arrow":
      return (
        <svg {...p}>
          <path d="M3 8h10M9 4l4 4-4 4" {...ST} />
        </svg>
      );
    case "close":
      return (
        <svg {...p}>
          <path d="M4.5 4.5l7 7M11.5 4.5l-7 7" {...ST} />
        </svg>
      );
    case "filter":
      return (
        <svg {...p}>
          <path d="M2.5 4.5h11M4.5 8h7M6.5 11.5h3" {...ST} />
        </svg>
      );
    case "check":
      return <LumenCheckGlyph />;
  }
}
```

### lib/beautiful-ui/lumen/sound.ts

```tsx
"use client";

import * as React from "react";

/**
 * Asks the page's sound layer (beautiful-ui-sound) to play `cue`. Silent when nothing listens, and inside
 * anything marked data-sound="off". `index` pitches select cues by position. No audio code ships in
 * the components themselves.
 */
export function emitSound(el: Element | null, cue: string, force?: boolean, index?: number) {
  if (!el || typeof CustomEvent === "undefined" || el.closest('[data-sound="off"]')) return;
  el.dispatchEvent(new CustomEvent("beautiful-ui:sound", { bubbles: true, detail: { cue, force, index } }));
}

/**
 * Plays the cue `pick` returns whenever `value` changes (never on mount): an error when a field
 * turns invalid, a success when a check passes.
 */
export function useCueOnChange<T>(ref: React.RefObject<Element | null>, value: T, pick: (next: T, prev: T) => string | null) {
  const prev = React.useRef(value);
  const pickRef = React.useRef(pick);
  React.useEffect(() => {
    pickRef.current = pick;
  });
  React.useEffect(() => {
    if (Object.is(prev.current, value)) return;
    const before = prev.current;
    prev.current = value;
    const cue = pickRef.current(value, before);
    if (cue) emitSound(ref.current, cue);
  }, [ref, value]);
}

/** Points one ref (callback or object) at `node`; returns how to let go of it. */
function assign<T>(r: React.Ref<T> | undefined, node: T | null): () => void {
  if (typeof r === "function") {
    const cleanup = r(node);
    return typeof cleanup === "function" ? cleanup : () => r(null);
  }
  if (r) {
    (r as React.RefObject<T | null>).current = node;
    return () => ((r as React.RefObject<T | null>).current = null);
  }
  return () => {};
}

/**
 * One stable callback ref for the component's own ref and the caller's. It changes only when one of
 * them does, so a caller's callback ref isn't detached and reattached on every render, and it
 * honours React 19 ref cleanups.
 */
export function useMergedRef<T>(own: React.Ref<T> | undefined, theirs: React.Ref<T> | undefined): React.RefCallback<T> {
  return React.useCallback(
    (node: T | null) => {
      const release = [assign(own, node), assign(theirs, node)];
      return () => release.forEach((f) => f());
    },
    [own, theirs],
  );
}

/** True when `el` sits in an input group whose status part speaks for it (one cue per change). */
export const statusSpeaksFor = (el: Element | null) => Boolean(el?.closest('[data-slot="input-group"]')?.querySelector('[data-slot="input-group-status"]'));
```

### CSS (the registry `css` / `cssVars`, merged into the global stylesheet by the shadcn CLI)

```css
:root {
  --lo-scrim: rgba(20,24,40,.18);
  --lo-panel: linear-gradient(180deg,rgba(255,255,255,.97),rgba(247,248,251,.96));
  --lo-panelSh: 0 0 0 1px rgba(20,24,40,.08),0 1px 2px rgba(20,24,40,.06),0 24px 60px -20px rgba(20,24,40,.35);
  --lo-rim: inset 0 1px 0 #fff;
}
.dark, [data-theme="dark"] {
  --lo-scrim: rgba(0,0,0,.5);
  --lo-panel: linear-gradient(180deg,rgba(38,40,46,.96),rgba(18,19,23,.97));
  --lo-panelSh: 0 0 0 1px rgba(0,0,0,.8),0 30px 80px -24px rgba(0,0,0,.95);
  --lo-rim: inset 0 0 0 1px rgba(255,255,255,.08),inset 0 1px 0 rgba(255,255,255,.12);
}
@layer components {
  @keyframes lumen-rise {
    from {
      opacity: 0;
      transform: translate(-50%,calc(-50% + 10px)) scale(.97);
    }
  }
  @keyframes lumen-sink {
    to {
      opacity: 0;
      transform: translate(-50%,calc(-50% + 4px)) scale(.985);
    }
  }
  @keyframes lumen-fade-in {
    from {
      opacity: 0;
    }
  }
  @keyframes lumen-fade-out {
    to {
      opacity: 0;
    }
  }
  .lumen-dialog-overlay {
    position: fixed;
    inset: 0;
    z-index: 50;
    background: var(--lo-scrim);
    -webkit-backdrop-filter: blur(6px);
    backdrop-filter: blur(6px);
  }
  .lumen-dialog-overlay:is([data-open],[data-state="open"]) {
    animation: lumen-fade-in var(--gc-t-fade) ease both;
  }
  .lumen-dialog-overlay:is([data-closed],[data-state="closed"]) {
    animation: lumen-fade-out var(--gc-t-fade) ease both;
  }
  .lumen-dialog {
    position: fixed;
    left: 50%;
    top: 50%;
    z-index: 50;
    display: grid;
    gap: 16px;
    box-sizing: border-box;
    width: 100%;
    max-width: calc(100% - 2rem);
    max-height: calc(100dvh - 2rem);
    overflow: auto;
    padding: 22px;
    border-radius: calc(20px * var(--lumen-radius-k, 1));
    background: var(--lo-panel);
    box-shadow: var(--lo-rim),var(--lo-panelSh);
    color: var(--gc-ink);
    font: 400 14px/1.5 var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
    outline: none;
    transform: translate(-50%,-50%);
  }
  .lumen-dialog:is([data-open],[data-state="open"]) {
    animation: lumen-rise calc(.45s * var(--gcp-k)) var(--gc-sp) both,lumen-fade-in var(--gc-t-fade) ease both;
  }
  .lumen-dialog:is([data-closed],[data-state="closed"]) {
    animation: lumen-sink calc(.18s * var(--gcp-k)) ease both,lumen-fade-out var(--gc-t-fade) ease both;
  }
  .lumen-dialog-close {
    position: absolute;
    top: 12px;
    inset-inline-end: 12px;
  }
  .lumen-dialog-header {
    display: flex;
    flex-direction: column;
    gap: 6px;
    padding-inline-end: 28px;
  }
  .lumen-dialog-title {
    margin: 0;
    font: 600 17px/1.3 var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
    letter-spacing: -.015em;
    color: var(--gc-ink);
  }
  .lumen-dialog-description {
    margin: 0;
    font: 400 13.5px/1.5 var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
    color: var(--gc-sec);
  }
  .lumen-dialog-description a {
    text-decoration: underline;
    text-underline-offset: 3px;
  }
  .lumen-dialog-footer {
    display: flex;
    flex-direction: column-reverse;
    gap: 8px;
    margin: 0 -22px -22px;
    padding: 14px 22px;
    border-top: 1px solid var(--gc-hair);
    border-radius: 0 0 calc(20px * var(--lumen-radius-k, 1)) calc(20px * var(--lumen-radius-k, 1));
    background: color-mix(in srgb,var(--gc-ink) 2.5%,transparent);
  }
  @media (min-width: 640px) {
    .lumen-dialog {
      max-width: 440px;
    }
    .lumen-dialog-footer {
      flex-direction: row;
      justify-content: flex-end;
    }
  }
}
```

The lumen foundation (the tokens listed above, keyframes and motion levels) installs once with the first component; its CSS is public at https://beautiful-ui.dev/r/lumen-foundation.json.
