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.
Copy the file below to src/lib/components/ui/responsive-dialog.ts. It imports cn from utils.ts, which belongs at src/lib/utils.ts, and dialog, drawer 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 {
context,
If,
mediaQuery,
signal,
type Child,
type ComponentProps,
type Signal,
} from "@implementjs/core";
import { createComponent } from "@implementjs/primitives";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from "./dialog";
import { Drawer, DrawerContent } from "./drawer";
/**
* One modal, two shapes: a centered dialog where there is room for one, and a
* drawer up from the bottom edge where a thumb is what is reaching for it.
*
* The two share everything below the panel — `Drawer` and `Dialog` are the same
* modal primitive underneath, so the title, description, and close are one
* component each and pick up whichever root they find themselves in. Only the
* root and the panel differ, and only the root reads the viewport.
*/
/** Below this the dialog becomes a drawer. Matches the sidebar's breakpoint. */
export const RESPONSIVE_DIALOG_QUERY = "(max-width: 767px)";
/**
* Which shape the surrounding root took. Read at mount, so a responsive dialog
* nested inside a real drawer cannot mistake the outer one for its own.
*/
const ResponsiveDialogCtx = context<"drawer" | "dialog">("ResponsiveDialogCtx");
export type ResponsiveDialogProps = ComponentProps<typeof Dialog> &
ComponentProps<typeof Drawer> & {
/** The media query that picks the drawer. Defaults to `(max-width: 767px)`. */
query?: string;
};
export type ResponsiveDialogTriggerProps = ComponentProps<typeof DialogTrigger>;
export type ResponsiveDialogContentProps = ComponentProps<typeof DialogContent> &
ComponentProps<typeof DrawerContent>;
export type ResponsiveDialogTitleProps = ComponentProps<typeof DialogTitle>;
export type ResponsiveDialogDescriptionProps = ComponentProps<typeof DialogDescription>;
export type ResponsiveDialogCloseProps = ComponentProps<typeof DialogClose>;
export const ResponsiveDialog = createComponent(function ResponsiveDialog(
{ open, query = RESPONSIVE_DIALOG_QUERY, ...props }: ResponsiveDialogProps,
...children: Child[]
) {
// one signal across both shapes, so a drawer left open on a phone that turns
// into a tablet comes back as an open dialog rather than nothing at all
const openSignal: Signal<boolean> = signal(open ?? false);
const isMobile = mediaQuery(query);
return If(isMobile)
.Then(
ResponsiveDialogCtx.Provide("drawer").To(Drawer({ ...props, open: openSignal }, ...children)),
)
.Else(
ResponsiveDialogCtx.Provide("dialog").To(
Dialog({ open: openSignal, preventScroll: props.preventScroll }, ...children),
),
);
});
export const ResponsiveDialogTrigger = createComponent(function ResponsiveDialogTrigger(
{ variant = "outline", ...props }: ResponsiveDialogTriggerProps,
...children: Child[]
) {
return DialogTrigger(
{ variant, ...props, "data-slot": "responsive-dialog-trigger" },
...children,
);
});
/**
* The panel: a centered dialog, or a drawer from the bottom edge. Both bring
* their own scrim and portal, and both take the props of whichever they are —
* `showHandle` reaches the drawer, `showCloseButton` reaches either.
*/
export const ResponsiveDialogContent = createComponent(function ResponsiveDialogContent(
props: ResponsiveDialogContentProps,
...children: Child[]
) {
return ResponsiveDialogCtx.Use((shape) =>
// each shape names itself: the panel comes back as data-slot="drawer-content"
// or "dialog-content", which is what a stylesheet or a test wants to know
shape === "drawer" ? DrawerContent(props, ...children) : DialogContent(props, ...children),
);
});
/**
* The title, description, and close are the same components under either shape:
* `Drawer` and `Dialog` are one primitive, so these resolve against whichever
* root is above them and pick up its data attributes.
*/
export const ResponsiveDialogTitle = createComponent(function ResponsiveDialogTitle(
props: ResponsiveDialogTitleProps,
...children: Child[]
) {
return DialogTitle(props, ...children);
});
export const ResponsiveDialogDescription = createComponent(function ResponsiveDialogDescription(
props: ResponsiveDialogDescriptionProps,
...children: Child[]
) {
return DialogDescription(props, ...children);
});
export const ResponsiveDialogClose = createComponent(function ResponsiveDialogClose(
props: ResponsiveDialogCloseProps,
...children: Child[]
) {
return DialogClose(props, ...children);
});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.
| 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. |
query | string | "(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.
| Prop | Type | Default | Description |
|---|---|---|---|
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.
| Prop | Type | Default | Description |
|---|---|---|---|
showHandle | boolean | true | Renders the drawer's grab bar. No effect on the dialog. |
showCloseButton | boolean | false on the drawer, true on the dialog | Renders a close button in the top right corner. |
overlay | ResponsiveDialogOverlayProps | {} | 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.
| 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. |