implement
Dialog

Dialog

A modal window over the page, with a scrim behind 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"),
		),
	);
}

Installation

npx jsrepo add @implementjs/ui/dialog

jsrepo pulls button along with it, and installs @implementjs/lucide.

Usage

DialogContent is a centered panel that scales in. It brings the scrim and the portal with it: DialogOverlay renders behind the panel and DialogPortal mounts the pair on document.body, so neither is yours to place. Both stay exported for a layout that composes the panel itself out of the primitives — pairing them with the styled DialogContent only gets you two overlays.

A close button in the top right corner is included. showCloseButton: false removes it, for a dialog that has to be answered through its own buttons — or use an alert dialog, which has no escape hatches at all.

import {
	Dialog,
	DialogClose,
	DialogContent,
	DialogDescription,
	DialogTitle,
	DialogTrigger,
} from "@/lib/components/ui/dialog";

Dialog(
	DialogTrigger({ variant: "outline" }, "Edit profile"),
	DialogContent(
		DialogTitle("Edit profile"),
		DialogDescription("Make changes to your profile here."),
		DialogClose({ variant: "default" }, "Save changes"),
	),
);

Nesting

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"),
				),
			),
		),
	);
}

Dialogs stack. The primitive counts the depth into --ip-nested-level and --ip-nested-count, and the styled classes spend them: the panel underneath scales back and shifts up, and the second overlay renders transparent rather than darkening the page twice.

Nothing to configure — open a dialog from inside a dialog and the stack behaves.

Controlling it

Pass open as a signal to drive the dialog from outside, with or without a trigger:

const open = signal(false);

Dialog({ open }, DialogContent(DialogTitle("Saved")));

API Reference

Every prop the styling does not consume is forwarded to the Dialog primitive, so the tables below are the whole surface — the behavior props and the styling ones together.

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
variant"default" | "destructive" | "outline" | "secondary" | "ghost" | "link""default"Which button style the part renders with. Also set as data-variant.
size"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg""default"The button's height and padding scale. Also set as data-size.
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. Styled as a fixed scrim that fades in and out; a nested dialog's overlay renders transparent so the stack does not darken twice. DialogContent renders one for you — this export is for composing a panel of your own. 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. Styled as a centered panel that scales in, and shifts up as further dialogs stack on top of it. Renders its own DialogOverlay inside a DialogPortal, so neither has to be placed by hand. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
showCloseButtonbooleantrueRenders a DialogClose in the top right corner. Turn it off for a dialog that must be dismissed through its own buttons.
overlayDialogOverlayProps{}Props for the overlay the content renders behind itself.
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.

PropTypeDefaultDescription
variant"default" | "destructive" | "outline" | "secondary" | "ghost" | "link""ghost"Which button style the part renders with. Also set as data-variant.
size"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg""sm"The button's height and padding scale. Also set as data-size.