implement
Responsive Dialog

Responsive Dialog

One modal that is a dialog on a desktop and a drawer on a phone.

Currently a dialog. Narrow the window past 768px to swap it.

import { Div, If, Label, P, mediaQuery } from "@implementjs/core";
import { Input } from "@/lib/components/ui/input";
import {
	ResponsiveDialog,
	ResponsiveDialogClose,
	ResponsiveDialogContent,
	ResponsiveDialogDescription,
	ResponsiveDialogTitle,
	ResponsiveDialogTrigger,
	RESPONSIVE_DIALOG_QUERY,
} from "@/lib/components/ui/responsive-dialog";

function Field(id: string, label: string, value: string) {
	return Div(
		{ class: "grid gap-2" },
		Label({ for: id, class: "text-sm" }, label),
		Input({
			id,
			value,
			class: "h-8",
		}),
	);
}

export default function ResponsiveDialogDemo() {
	const isMobile = mediaQuery(RESPONSIVE_DIALOG_QUERY);

	return Div(
		{ class: "flex flex-col items-center gap-3" },
		ResponsiveDialog(
			ResponsiveDialogTrigger("Edit profile"),
			ResponsiveDialogContent(
				Div(
					{ class: "mx-auto flex w-full max-w-sm flex-col gap-6 p-4 pb-8 md:p-0" },
					Div(
						{ class: "grid gap-1.5" },
						ResponsiveDialogTitle("Edit profile"),
						ResponsiveDialogDescription("Make changes to your profile here."),
					),
					Div(
						{ class: "grid gap-3" },
						Field("responsive-name", "Name", "Aidan Bleser"),
						Field("responsive-username", "Username", "@ieedan"),
					),
					ResponsiveDialogClose({ variant: "default" }, "Save changes"),
				),
			),
		),
		P(
			{ class: "text-xs text-muted-foreground" },
			"Currently a ",
			If(isMobile).Then("drawer").Else("dialog"),
			". Narrow the window past 768px to swap it.",
		),
	);
}

Installation

npx jsrepo add @implementjs/ui/responsive-dialog

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

Usage

Write it once. Below 768px it is a drawer up from the bottom edge, draggable and dismissable with a thumb; above it, a centered dialog.

import {
	ResponsiveDialog,
	ResponsiveDialogClose,
	ResponsiveDialogContent,
	ResponsiveDialogDescription,
	ResponsiveDialogTitle,
	ResponsiveDialogTrigger,
} from "@/lib/components/ui/responsive-dialog";

ResponsiveDialog(
	ResponsiveDialogTrigger("Edit profile"),
	ResponsiveDialogContent(
		ResponsiveDialogTitle("Edit profile"),
		ResponsiveDialogDescription("Make changes to your profile here."),
		ResponsiveDialogClose({ variant: "default" }, "Save changes"),
	),
);

The panel has no padding of its own, because the two shapes want different padding: a drawer wants room for a thumb at the bottom, a dialog does not. A md: prefix is enough: the default breakpoint is (max-width: 767px), which is exactly the edge Tailwind's md starts at.

ResponsiveDialogContent(
	Div({ class: "mx-auto flex w-full max-w-sm flex-col gap-6 p-4 pb-8 md:p-0" }, …),
);

How it switches

The root reads mediaQuery once and renders either a Drawer or a Dialog around your children. Both shapes share one open signal, so a drawer left open on a phone that becomes a tablet comes back as an open dialog rather than nothing at all.

Only the root and the panel differ. ResponsiveDialogTitle, ResponsiveDialogDescription, and ResponsiveDialogClose are one component each: Drawer and Dialog are the same modal primitive underneath, so a title resolves against whichever root it finds itself in and picks up that root's data attributes.

Pass query to move the breakpoint:

ResponsiveDialog({ query: "(max-width: 1023px)" }, …);

Props reach the shape they belong to

ResponsiveDialogContent takes the panel props of both. showHandle and the drawer's other content props reach the drawer; showCloseButton reaches either. Root props work the same way — snapPoints, direction, and dismissible reach the drawer, and preventScroll reaches both.

ResponsiveDialog(
	{ snapPoints: [0.5, 1] },
	ResponsiveDialogTrigger("Filters"),
	ResponsiveDialogContent(ResponsiveDialogTitle("Filters"), …),
);

A prop the current shape does not have is simply not used, so there is nothing to guard.

Controlling it

Pass open as a signal to drive it from outside, with or without a trigger. The signal survives the switch between shapes:

const open = signal(false);

ResponsiveDialog({ open }, ResponsiveDialogContent(ResponsiveDialogTitle("Saved")));

API Reference

ResponsiveDialog

The root. Reads the viewport once and renders a Drawer below the breakpoint or a Dialog above it, around the same children. Both shapes share one open signal, so the switch does not lose the open state. Every other prop reaches the shape it belongs to — snapPoints, direction, dismissible and the rest of the drawer's root props reach the drawer; preventScroll reaches both.

PropTypeDefaultDescription
openSignal<boolean> | booleanfalseThe open state. Pass a signal to control it from outside; a boolean seeds uncontrolled state.
querystring"(max-width: 767px)"The media query that picks the drawer. Exported as RESPONSIVE_DIALOG_QUERY for anything that has to agree with it.

ResponsiveDialogTrigger

Toggles it open and closed. The same trigger under either shape. Renders a Button; extra props are forwarded onto it.

PropTypeDefaultDescription
variant"default" | "destructive" | "outline" | "secondary" | "ghost" | "link""outline"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.

ResponsiveDialogContent

The panel: a centered dialog, or a drawer from the bottom edge. Both bring their own scrim and portal. It takes the content props of both shapes, and each one reaches the shape that has it — showHandle the drawer, showCloseButton either. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
showHandlebooleantrueRenders the drawer's grab bar. No effect on the dialog.
showCloseButtonbooleanfalse on the drawer, true on the dialogRenders a close button in the top right corner.
overlayResponsiveDialogOverlayProps{}Props for the scrim the content renders behind itself.

ResponsiveDialogTitle

The heading, and the panel's accessible name. One component under either shape: Drawer and Dialog are the same modal primitive, so it picks up whichever root is above it. Renders a H2; extra props are forwarded onto it.

ResponsiveDialogDescription

Supporting text, wired to the panel's aria-describedby. One under either shape. Renders a P; extra props are forwarded onto it.

ResponsiveDialogClose

Closes it. One under either shape. 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.