Feedback
Dialog
A second-layer modal. The overlay blurs the page while the panel uses a solid surface.
<script>
import { Dialog, Button } from "@codeatlasdev/ui";
let open = $state(false);
</script>
<Dialog bind:open title="Confirm" description="This cannot be undone.">
{#snippet actions()}
<Button variant="ghost" onclick={() => (open = false)}>Cancel</Button>
<Button variant="destructive" onclick={remove}>Delete</Button>
{/snippet}
</Dialog>
The dialog renders a native modal <dialog> opened with showModal(): the page behind is inert, focus is trapped, scrolling is locked and Escape closes it. Clicking the overlay closes it too. For decisions that need an explicit answer use AlertDialog. On phones, prefer Sheet for bottom-anchored content.
Confirmation
The destructive confirm and cancel pattern.
Code
<script lang="ts">
import { Dialog, Button } from '@codeatlasdev/ui';
import { Trash2 } from '@lucide/svelte';
let open = $state(false);
let confirmed = $state(false);
</script>
<Button variant="destructive" onclick={() => ((confirmed = false), (open = true))}>
{#snippet leading()}<Trash2 size={14} />{/snippet}
Delete item
</Button>
<Dialog
bind:open
title="Delete this item?"
description="This action cannot be undone. The item will be permanently removed from your workspace."
size="sm"
>
{#snippet actions()}
<Button variant="ghost" onclick={() => (open = false)}>Cancel</Button>
<Button variant="destructive" onclick={() => { confirmed = true; open = false; }}>Delete</Button>
{/snippet}
</Dialog>
{#if confirmed}<p class="feedback" aria-live="polite">Item deleted.</p>{/if}
<style>
.feedback { margin: 0; font-size: 13px; color: var(--ca-semantic-error); font-style: italic; }
</style>
Info
Informational content with a single action.
Code
<script lang="ts">
import { Dialog, Button } from '@codeatlasdev/ui';
import { Info } from '@lucide/svelte';
let open = $state(false);
</script>
<Button variant="outlined" onclick={() => (open = true)}>
{#snippet leading()}<Info size={14} />{/snippet}
View details
</Button>
<Dialog bind:open title="About this component">
<p class="body">
<strong>Dialog</strong> renders a native modal <code><dialog></code>. It traps focus
(Tab cycles inside), closes on Escape and makes the page behind inert.
</p>
<p class="body">
The overlay blurs the page while the panel uses a solid <code>--ca-surface-overlay</code>,
so there is no double blur over the scrim.
</p>
{#snippet actions()}
<Button variant="filled" onclick={() => (open = false)}>Got it</Button>
{/snippet}
</Dialog>
<style>
.body {
font-size: var(--ca-font-size-body2);
color: var(--ca-text-secondary);
line-height: 1.6;
margin: 0 0 10px;
}
.body:last-child { margin-bottom: 0; }
.body strong { color: var(--ca-text-primary); }
.body code {
font-family: var(--ca-font-mono);
font-size: 0.88em;
color: var(--ca-brand-text);
background: color-mix(in oklch, var(--ca-brand-main) 10%, transparent);
padding: 1px 5px;
border-radius: var(--ca-radius-xs);
}
</style>
A dialog holding a form, at the md size.
Code
<script lang="ts">
import { Dialog, Button, TextField, Select, Stack } from '@codeatlasdev/ui';
let open = $state(false);
let name = $state('');
let email = $state('');
let role = $state('viewer');
const roleOpts = [
{ value: 'admin', label: 'Admin' },
{ value: 'editor', label: 'Editor' },
{ value: 'viewer', label: 'Viewer' },
];
</script>
<Button variant="filled" onclick={() => (open = true)}>Invite member</Button>
<Dialog bind:open title="Invite team member" size="md">
<Stack gap="3.5">
<TextField label="Full name" bind:value={name} placeholder="Ada Lovelace" />
<TextField label="Email" type="email" bind:value={email} placeholder="ada@company.com" />
<Select label="Role" bind:value={role} options={roleOpts} />
</Stack>
{#snippet actions()}
<Button variant="ghost" onclick={() => (open = false)}>Cancel</Button>
<Button variant="filled" disabled={!name || !email} onclick={() => (open = false)}>Send invite</Button>
{/snippet}
</Dialog>
Chromeless
No header, description or actions, and the body has no padding. The title becomes the visually hidden accessible name.
Code
<script lang="ts">
import { Dialog, Button } from '@codeatlasdev/ui';
let open = $state(false);
</script>
<Button variant="outlined" onclick={() => (open = true)}>Open chromeless</Button>
<Dialog bind:open title="Preview" size="md" chromeless>
<div class="media">
<p>Edge-to-edge content. Close with Esc or by clicking outside.</p>
<Button variant="filled" onclick={() => (open = false)}>Close</Button>
</div>
</Dialog>
<style>
.media {
display: grid;
gap: 12px;
padding: 24px;
justify-items: start;
color: var(--ca-text-secondary);
font-size: var(--ca-font-size-body2);
}
.media p { margin: 0; }
</style>
Split
size="split" is 1100 by 720 with two columns from the md breakpoint (900px) and a single column below it. Combine it with chromeless.
Code
<script lang="ts">
import { Dialog, Button, TextField, Stack } from '@codeatlasdev/ui';
let open = $state(false);
let name = $state('');
let email = $state('');
</script>
<Button variant="filled" onclick={() => (open = true)}>Open split</Button>
<Dialog bind:open title="Lead details" size="split" chromeless>
<div class="media">Media / summary</div>
<div class="form">
<Stack gap="3.5">
<TextField label="Full name" bind:value={name} placeholder="Ada Lovelace" />
<TextField label="Email" type="email" bind:value={email} placeholder="ada@company.com" />
<Button variant="filled" onclick={() => (open = false)}>Save</Button>
</Stack>
</div>
</Dialog>
<style>
.media {
display: grid;
place-items: center;
min-height: 160px;
background: color-mix(in oklab, var(--ca-brand-main) 12%, var(--ca-surface-overlay));
color: var(--ca-text-secondary);
font-size: var(--ca-font-size-body2);
}
.form { padding: 24px; overflow-y: auto; }
</style>
Props
| Prop | Type | Default | Description |
|---|
open | boolean | false | Bindable open state. |
title | string | – | Header title. With chromeless it is the hidden accessible name. |
description | string | – | Subtitle below the title. |
size | 'sm' | 'md' | 'lg' | 'xl' | 'split' | 'md' | Width: 400, 480, 600 or 820px. split is 1100 by 720 in two columns from 900px. |
chromeless | boolean | false | Omits header, description and actions; the body has no padding. title and description stay available to screen readers. |
children | Snippet | – | Dialog body. |
actions | Snippet | – | Footer buttons. |
onOpenChange | (open: boolean) => void | – | Called whenever the open state changes. |
class | string | '' | Extra classes on the panel. |
Layers
Dialogs, sheets, drawers, menus, popovers and tooltips share one layer stack. Modals are native <dialog> elements opened with showModal(); menus, popovers and tooltips are popover elements. Both live in the browser’s top layer, so a layer opened later is always on top: there is no z-index to manage, and a menu inside a dialog just works. Anchored layers use CSS anchor positioning where the browser supports it and fall back to Floating UI elsewhere.
Escape and outside presses go to the topmost layer only, so a menu inside a dialog closes first and the dialog on the next press. Focus returns to the trigger when a layer closes.
A host that draws outside the DOM (a native view over a webview) can follow the stack with onLayerChange. It fires on every open and close with the open layers, bottom to top, and their rectangles, and returns the function that unsubscribes:
import { onLayerChange, type LayerChange } from "@codeatlasdev/ui";
const stop = onLayerChange((change: LayerChange) => {
nativeView.setParked(change.count > 0);
// change.layers: LayerInfo[], bottom to top
// each LayerInfo is { kind: LayerKind, rect: DOMRect }
});
The same signal is a layerchange CustomEvent on document, with the detail as event.detail. LayerChange is { layers: LayerInfo[]; count: number; modal: boolean }, where modal is true while at least one modal layer is open. LayerKind is "modal" for Dialog, AlertDialog, Drawer and Sheet, "popover" for menus and popovers, and "hint" for tooltips. All three types are exported from @codeatlasdev/ui.