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.
Copy the file below to src/lib/components/ui/dialog.ts. It imports cn from utils.ts, which belongs at src/lib/utils.ts, and button from the same directory — copy those in beside it too. Then, on top of @implementjs/core and @implementjs/primitives:
npm install @implementjs/lucide
import { Span, type Child, type ComponentProps } from "@implementjs/core";
import { XIcon } from "@implementjs/lucide";
import {
Dialog as DialogPrimitive,
DialogClose as DialogClosePrimitive,
DialogContent as DialogContentPrimitive,
DialogDescription as DialogDescriptionPrimitive,
DialogOverlay as DialogOverlayPrimitive,
DialogPortal as DialogPortalPrimitive,
DialogTitle as DialogTitlePrimitive,
DialogTrigger as DialogTriggerPrimitive,
} from "@implementjs/primitives";
import { buttonVariants, type ButtonSize, type ButtonVariant } from "./button";
import { cn } from "@/lib/utils";
import { createComponent } from "@implementjs/primitives";
export type DialogProps = ComponentProps<typeof DialogPrimitive>;
export type DialogTriggerProps = ComponentProps<typeof DialogTriggerPrimitive> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
export type DialogContentProps = ComponentProps<typeof DialogContentPrimitive> & {
showCloseButton?: boolean;
/** Props for the overlay the content renders behind itself. */
overlay?: DialogOverlayProps;
};
export type DialogOverlayProps = ComponentProps<typeof DialogOverlayPrimitive>;
export type DialogCloseProps = ComponentProps<typeof DialogClosePrimitive> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
export type DialogTitleProps = ComponentProps<typeof DialogTitlePrimitive>;
export type DialogDescriptionProps = ComponentProps<typeof DialogDescriptionPrimitive>;
export const DialogPortal = DialogPortalPrimitive;
export const Dialog = createComponent(function Dialog(props: DialogProps, ...children: Child[]) {
return DialogPrimitive(props, ...children);
});
export const DialogTrigger = createComponent(function DialogTrigger(
{
class: className,
variant = "default",
size = "default",
type = "button",
...props
}: DialogTriggerProps,
...children: Child[]
) {
return DialogTriggerPrimitive(
{
type,
...props,
"data-slot": "dialog-trigger",
"data-variant": variant,
"data-size": size,
class: cn(buttonVariants({ variant, size }), className),
},
...children,
);
});
export const DialogOverlay = createComponent(function DialogOverlay(
{ class: className, ...props }: DialogOverlayProps,
...children: Child[]
) {
return DialogOverlayPrimitive(
{
...props,
"data-slot": "dialog-overlay",
class: cn(
"fixed inset-0 z-[calc(50+var(--ip-nested-level,0))] bg-black/50",
"transition-[opacity,display] duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] transition-discrete motion-reduce:transition-none",
"data-[state=open]:block data-[state=open]:opacity-100",
"data-[state=closed]:pointer-events-none data-[state=closed]:hidden data-[state=closed]:opacity-0",
"starting:data-[state=open]:opacity-0",
"data-[nested]:bg-transparent",
className,
),
},
...children,
);
});
export const DialogContent = createComponent(function DialogContent(
{ class: className, showCloseButton = true, overlay = {}, ...props }: DialogContentProps,
...children: Child[]
) {
return DialogPortal(
DialogOverlay(overlay),
DialogContentPrimitive(
{
...props,
"data-slot": "dialog-content",
class: cn(
"fixed top-1/2 left-1/2 z-[calc(50+var(--ip-nested-level,0))] grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border bg-background p-6 text-foreground shadow-lg outline-none sm:max-w-lg",
"transition-[opacity,scale,translate,display] duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] transition-discrete motion-reduce:transition-none",
"data-[state=open]:grid data-[state=open]:scale-[calc(1-0.05*var(--ip-nested-count,0))] data-[state=open]:opacity-100",
"data-[state=closed]:pointer-events-none data-[state=closed]:hidden data-[state=closed]:scale-95 data-[state=closed]:opacity-0",
"starting:data-[state=open]:opacity-0 starting:data-[state=open]:scale-95",
"data-[nested-open]:-translate-y-[calc(50%+(0.5rem*var(--ip-nested-count,0)))]",
className,
),
},
...children,
showCloseButton
? DialogClose(
{
variant: "ghost",
size: "icon-sm",
class: "absolute top-3 right-3",
},
XIcon({ class: "size-4", "aria-hidden": true }),
Span({ class: "sr-only" }, "Close"),
)
: null,
),
);
});
export const DialogTitle = createComponent(function DialogTitle(
{ class: className, ...props }: DialogTitleProps,
...children: Child[]
) {
return DialogTitlePrimitive(
{
...props,
"data-slot": "dialog-title",
class: cn("text-lg leading-none font-semibold", className),
},
...children,
);
});
export const DialogDescription = createComponent(function DialogDescription(
{ class: className, ...props }: DialogDescriptionProps,
...children: Child[]
) {
return DialogDescriptionPrimitive(
{
...props,
"data-slot": "dialog-description",
class: cn("text-sm text-muted-foreground", className),
},
...children,
);
});
export const DialogClose = createComponent(function DialogClose(
{ class: className, variant = "ghost", size = "sm", type = "button", ...props }: DialogCloseProps,
...children: Child[]
) {
return DialogClosePrimitive(
{
type,
...props,
"data-slot": "dialog-close",
"data-variant": variant,
"data-size": size,
class: cn(buttonVariants({ variant, size }), className),
},
...children,
);
});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.
| Prop | Type | Default | Description |
|---|---|---|---|
open | Signal<boolean> | boolean | false | The open state. Pass a signal to control it from outside; a boolean seeds uncontrolled state. |
preventScroll | boolean | true | When 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.
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
default | boolean | false | When the dialog starts open, return focus to this trigger instead of the first one in the tree. |
| Data attribute | Value |
|---|---|
[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 attribute | Value |
|---|---|
[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 variable | Description |
|---|---|
--ip-nested-count | How 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-level | This 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.
| Prop | Type | Default | Description |
|---|---|---|---|
showCloseButton | boolean | true | Renders a DialogClose in the top right corner. Turn it off for a dialog that must be dismissed through its own buttons. |
overlay | DialogOverlayProps | {} | Props for the overlay the content renders behind itself. |
| Data attribute | Value |
|---|---|
[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 variable | Description |
|---|---|
--ip-nested-count | How 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-level | This 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 attribute | Value |
|---|---|
[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 attribute | Value |
|---|---|
[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.
| Prop | Type | Default | Description |
|---|---|---|---|
to | HTMLElement | Readable<HTMLElement> | document.body | The element to mount into. Also available as chained .To(target). |
disabled | boolean | Readable<boolean> | false | Mount 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.
| Prop | Type | Default | Description |
|---|---|---|---|
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. |

