implement
Dialog

Dialog

A modal window that interrupts the page until the user dismisses it.

import { Div, Input, Label, Span, Switch, signal } from "@implementjs/core";
import { CrownIcon, ShieldCheckIcon, UserIcon, type IconComponent } from "@implementjs/lucide";
import {
	Dialog,
	DialogClose,
	DialogContent,
	DialogDescription,
	DialogTitle,
	DialogTrigger,
} from "@/lib/components/ui/dialog";
import {
	Select,
	SelectContent,
	SelectItem,
	SelectTrigger,
	SelectValue,
} from "@/lib/components/ui/select";

function Field(id: string, label: string, value: string) {
	return Div(
		{ class: "grid grid-cols-4 items-center gap-4" },
		Label({ for: id, class: "text-sm" }, label),
		Input({
			id,
			value,
			class:
				"col-span-3 h-8 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
		}),
	);
}

const roles: { value: string; label: string; icon: IconComponent }[] = [
	{ value: "user", label: "User", icon: UserIcon },
	{ value: "moderator", label: "Moderator", icon: ShieldCheckIcon },
	{ value: "admin", label: "Admin", icon: CrownIcon },
];

function RoleIcon(icon: IconComponent) {
	return icon({ class: "size-4 shrink-0", "aria-hidden": true });
}

export default function DialogDemo() {
	const role = signal<string | null>("user");

	return Dialog(
		DialogTrigger({ variant: "outline" }, "Edit profile"),
		DialogContent(
			Div(
				{ class: "grid gap-1.5" },
				DialogTitle("Edit profile"),
				DialogDescription("Make changes to your profile here. Click save when you're done."),
			),
			Div(
				{ class: "grid gap-3" },
				Field("name", "Name", "Aidan Bleser"),
				Field("username", "Username", "@ieedan"),
				Div(
					{ class: "grid grid-cols-4 items-center gap-4" },
					Label({ for: "role", class: "text-sm" }, "Role"),
					Div(
						{ class: "col-span-3" },
						Select(
							{ value: role, items: roles },
							SelectTrigger(
								{ id: "role" },
								Span(
									{ class: "flex min-w-0 flex-1 items-center gap-2" },
									Switch(role)
										.Case("user", RoleIcon(UserIcon))
										.Case("moderator", RoleIcon(ShieldCheckIcon))
										.Case("admin", RoleIcon(CrownIcon)),
									SelectValue({
										placeholder: "Select a role",
									}),
								),
							),
							SelectContent(
								...roles.map((item) =>
									SelectItem(
										{ value: item.value },
										Span({ class: "flex items-center gap-2" }, RoleIcon(item.icon), item.label),
									),
								),
							),
						),
					),
				),
			),
			DialogClose({ variant: "outline", class: "w-full" }, "Save changes"),
		),
	);
}

A dialog is a panel that opens over the page. Dialog is the root, DialogTrigger is the control that toggles it, and DialogContent is the panel. Wrap the overlay and panel in DialogPortal when they need to escape overflow, and put DialogClose inside the panel for a dismiss control.

import {
	Dialog,
	DialogClose,
	DialogContent,
	DialogDescription,
	DialogOverlay,
	DialogPortal,
	DialogTitle,
	DialogTrigger,
} from "@implementjs/primitives";

Dialog(
	DialogTrigger("Edit profile"),
	DialogPortal(
		DialogOverlay(),
		DialogContent(
			DialogTitle("Edit profile"),
			DialogDescription("Make changes to your profile here."),
			DialogClose("Save changes"),
		),
	),
);

Each part accepts optional props and children — pass a props object when you need attributes, or pass children directly. See createComponent. Extra props on the trigger, overlay, content, title, description, and close are forwarded onto the underlying Button, Div, H2, or P.

Open state

Dialog owns whether the panel is open. Pass a boolean to seed it, or a signal to control it from outside (signal() returns a writable unchanged, so the same prop accepts both):

const open = signal(false);

Dialog({ open }, DialogTrigger("Edit profile"), DialogContent("Hello"));

Button({ onClick: () => open.set(false) }, "Close");

If it starts open (open: true, or a signal that's already true) focus moves into the panel on mount. Closing returns focus to the trigger that opened it, or the one marked default.

While open, the page behind cannot scroll. Pass preventScroll: false to leave it scrollable. The overlay and panel can still scroll if you give them overflow.

Overlay and content

DialogOverlay is a Div that covers the page behind the panel. DialogContent is a Div with role="dialog" and aria-modal. Style them against data-state; the primitive does not hide them for you, and it does not position the panel. Center it with CSS.

Clicking the overlay dismisses the dialog, because the overlay sits outside the content.

Title and description

DialogTitle is an H2 and DialogDescription is a P. Put them inside the content. The content's aria-labelledby and aria-describedby point at them, so the accessible name comes from the heading instead of the trigger.

DialogContent(DialogTitle("Edit profile"), DialogDescription("Make changes to your profile here."));

If you skip the title, set aria-label on the content yourself.

Portal

DialogPortal is the Portal helper under a dialog name. It renders its children into document.body by default so the overlay and panel are not clipped by overflow or trapped in a parent stacking context. Context still resolves from where you declared it.

Wrap DialogOverlay and DialogContent in it. Chain .To(target) or pass to to pick a different parent, and disabled to mount in place instead.

DialogPortal(DialogOverlay(), DialogContent("Hello"));

DialogPortal({ to: overlayRoot }, DialogOverlay(), DialogContent("Hello"));

Close

DialogClose is a Button that sets the dialog closed. Put it inside the content for a Done or dismiss control. You can still close from outside by writing the open signal. Escape and clicking outside the content also close it.

DialogContent("Place content for the dialog here.", DialogClose("Done"));

Multiple triggers

A dialog can have more than one trigger. They share a single panel. Click the same trigger again to close; click a different one to keep it open and remember that button for focus return.

When the dialog starts open, it still has to pick a trigger to return focus to. That's the first trigger in the tree, unless you pass default on a different one:

Dialog(
	{ open: true },
	DialogTrigger("Left"),
	DialogTrigger({ default: true }, "Center"),
	DialogTrigger("Right"),
	DialogContent("Starts open. Closing returns focus to Center."),
);

Nested

Each Dialog provides its own context, so a second root inside the content talks to its own trigger, panel, and close. Put the inner trigger in the outer panel.

Nested dialogs know their parent. Overlay and content get data-nested when they sit inside another dialog. While the inner one is open, every ancestor gets data-nested-open, data-nested-count, and --ip-nested-count so you can scale those panels back into a stack. Nested dialogs also set --ip-nested-level (0 for the outermost) so you can raise their z-index above the parent. Closing a parent closes the nested dialogs with it, so both can portal to document.body without leaving an orphan panel behind.

Keep the inner portal enabled. If you disable it, the nested panel lives inside the parent and scales with it instead of stacking on top.

Dialog(
	DialogTrigger("Share"),
	DialogPortal(
		DialogOverlay(),
		DialogContent(
			DialogTitle("Share"),
			DialogDescription("Anyone with the link can view this project."),
			Dialog(
				DialogTrigger("Invite"),
				DialogPortal(
					DialogOverlay(),
					DialogContent(
						DialogTitle("Invite"),
						DialogDescription("They'll get an email to join this project."),
						DialogClose("Send invite"),
					),
				),
			),
		),
	),
);
import { Div, Input, Label, P, Span } from "@implementjs/core";
import { Avatar, AvatarFallback, AvatarImage } from "@/lib/components/ui/avatar";
import {
	Dialog,
	DialogClose,
	DialogContent,
	DialogDescription,
	DialogTitle,
	DialogTrigger,
} from "@/lib/components/ui/dialog";

function Person(src: string, alt: string, initials: string, name: string, access: string) {
	return Div(
		{ class: "flex items-center gap-3" },
		Avatar({ class: "size-8" }, AvatarImage({ src, alt }), AvatarFallback(initials)),
		Span({ class: "min-w-0 flex-1 text-sm font-medium" }, name),
		Span({ class: "text-xs text-muted-foreground" }, access),
	);
}

export default function DialogNestedDemo() {
	return Dialog(
		DialogTrigger({ variant: "outline" }, "Share"),
		DialogContent(
			Div(
				{ class: "grid gap-1.5" },
				DialogTitle("Share"),
				DialogDescription("Anyone with the link can view this project."),
			),
			Div(
				{ class: "rounded-md border bg-muted/40 px-3 py-2" },
				P({ class: "truncate font-mono text-xs" }, "implementjs.dev/p/aurora"),
			),
			Div(
				{ class: "grid gap-3" },
				Person("https://github.com/ieedan.png", "@ieedan", "AB", "Aidan Bleser", "Owner"),
				Person("https://github.com/github.png", "@github", "GH", "GitHub", "Can edit"),
			),
			Dialog(
				DialogTrigger({ variant: "outline", class: "w-full" }, "Invite"),
				DialogContent(
					Div(
						{ class: "grid gap-1.5" },
						DialogTitle("Invite"),
						DialogDescription("They'll get an email to join this project."),
					),
					Div(
						{ class: "grid gap-2" },
						Label({ for: "invite-email", class: "text-sm" }, "Email"),
						Input({
							id: "invite-email",
							type: "email",
							placeholder: "ada@example.com",
							class:
								"h-8 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
						}),
					),
					DialogClose({ variant: "outline", class: "justify-self-end" }, "Send invite"),
				),
			),
		),
	);
}

Style the stack against those attributes. --ip-nested-count is the number of open descendants, so deeper stacks can recede further. --ip-nested-level is this dialog's depth, so nested panels paint above the parent even when the parent is attached to the document later:

DialogContent({
	class:
		"fixed top-1/2 left-1/2 z-[calc(50+var(--ip-nested-level,0))] -translate-x-1/2 -translate-y-1/2 transition-[scale,translate] data-[state=open]:scale-[calc(1-0.05*var(--ip-nested-count,0))] data-[nested-open]:-translate-y-[calc(50%+(0.5rem*var(--ip-nested-count,0)))]",
});

DialogOverlay({
	class:
		"fixed inset-0 z-[calc(50+var(--ip-nested-level,0))] bg-black/50 data-[nested]:bg-transparent",
});

Styling

Trigger, overlay, and content expose data-state as "open" or "closed". Overlay and content stay in the tree while closed; hide them with CSS. Center the content with fixed and a transform, not floating UI.

DialogTrigger({ class: "rounded-md border px-3 py-2 text-sm" }, "Edit profile");

DialogOverlay({
	class: "fixed inset-0 z-50 bg-black/50 data-[state=closed]:hidden data-[state=closed]:opacity-0",
});

DialogContent(
	{
		class:
			"fixed top-1/2 left-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-6 shadow-lg data-[state=closed]:hidden data-[state=closed]:scale-95",
	},
	DialogTitle({ class: "text-lg font-semibold" }, "Edit profile"),
	DialogDescription(
		{ class: "text-sm text-muted-foreground" },
		"Make changes to your profile here.",
	),
);

data-state is there for visibility and open versus closed. The overlay sits behind the panel; put it before DialogContent in the portal so the panel stacks on top. Nested dialogs add data-nested, data-nested-open, and --ip-nested-count for stack motion, covered above.

API Reference

Dialog

The root. Owns whether the dialog is open and provides that to the parts inside it.

PropTypeDefaultDescription
openSignal<boolean> | booleanfalseThe open state. Pass a signal to control it from outside; a boolean seeds uncontrolled state.
preventScrollbooleantrueWhen true, the page behind cannot scroll while the dialog is open. The overlay and panel can still scroll if you give them overflow.

DialogTrigger

Toggles the dialog open and closed. Clicking a different trigger keeps it open and remembers that button for focus return. Renders a Button; extra props are forwarded onto it.

PropTypeDefaultDescription
defaultbooleanfalseWhen the dialog starts open, return focus to this trigger instead of the first one in the tree.
Data attributeValue
[data-dialog-trigger]Present
[data-state]"open" | "closed"

DialogOverlay

The backdrop behind the panel. Style it against data-state; the primitive does not hide it for you. Renders a Div; extra props are forwarded onto it.

Data attributeValue
[data-dialog-overlay]Present
[data-state]"open" | "closed"
[data-nested]Present when this dialog is nested in another
[data-nested-open]Present when a nested dialog is open
[data-nested-count]Number of open nested dialogs
[data-nested-level]Depth in the stack; 0 is the outermost dialog
CSS variableDescription
--ip-nested-countHow many nested dialogs are open above this one. Use it to scale or translate the parent in a stack, e.g. scale(calc(1 - 0.05 * var(--ip-nested-count))).
--ip-nested-levelThis dialog's depth in the stack, 0 for the outermost. Raise z-index with it so nested dialogs paint above their parent, e.g. z-index: calc(50 + var(--ip-nested-level)).

DialogContent

The panel. Sets role="dialog" and aria-modal. Style it against data-state; the primitive does not hide or position it for you. Renders a Div; extra props are forwarded onto it.

Data attributeValue
[data-dialog-content]Present
[data-state]"open" | "closed"
[data-nested]Present when this dialog is nested in another
[data-nested-open]Present when a nested dialog is open
[data-nested-count]Number of open nested dialogs
[data-nested-level]Depth in the stack; 0 is the outermost dialog
CSS variableDescription
--ip-nested-countHow many nested dialogs are open above this one. Use it to scale or translate the parent in a stack, e.g. scale(calc(1 - 0.05 * var(--ip-nested-count))).
--ip-nested-levelThis dialog's depth in the stack, 0 for the outermost. Raise z-index with it so nested dialogs paint above their parent, e.g. z-index: calc(50 + var(--ip-nested-level)).

DialogTitle

The heading. Put it inside the content. Wires up aria-labelledby on the panel. Renders a H2; extra props are forwarded onto it.

Data attributeValue
[data-dialog-title]Present

DialogDescription

Supporting text. Put it inside the content. Wires up aria-describedby on the panel. Renders a P; extra props are forwarded onto it.

Data attributeValue
[data-dialog-description]Present

DialogPortal

Renders its children into another DOM parent so the overlay and panel escape overflow and stacking. This is the core Portal helper; context still resolves from where the portal is declared.

PropTypeDefaultDescription
toHTMLElement | Readable<HTMLElement>document.bodyThe element to mount into. Also available as chained .To(target).
disabledboolean | Readable<boolean>falseMount in place instead of teleporting. Keep nested dialogs portaled so they stack above the parent. Also available as chained .Disabled(value).

DialogClose

Closes the dialog when clicked. Put it inside the content. Renders a Button; extra props are forwarded onto it.