Drawer
Use drawer on a <dialog> element to create an edge-anchored modal drawer. It slides in/out via a CSS transform transition, calls the native showModal()/close() API, locks page scroll while open, and closes on backdrop click. The browser automatically traps focus inside the open drawer and restores focus when it closes.
Note: The patch automatically sets
aria-modal="true"on the<dialog>element and closes the drawer via the animated state path when Escape is pressed (intercepting the nativecancelevent). Focus restoration onclose()is handled natively by the<dialog>API. Escape dismisses only the topmost layer — while a floating panel (popover,selectBox,combobox,datePicker) is open inside the drawer, Escape closes that panel first.
The drawer surface uses a high elevation() box-shadow (no outline — it's edge-flush against the viewport, so a border would look out of place).
Props
| Prop | Type | Default | Description |
|---|---|---|---|
color | ThemeColor | "neutral" | Theme color tone for the drawer surface. |
open | ValueOrState<boolean> | false | Controls whether the drawer is open. Accepts a boolean, writable State, or read-only Computed/ReadableState. When the source is read-only, pass onDismiss so Escape/backdrop can close. |
onDismiss | () => void | — | Called when Escape, backdrop click, or requestClose requests close. Required to close when open is read-only. A writable State still set(false). |
placement | "left" | "right" | "top" | "bottom" | "start" | "end" | "end" | Edge the drawer slides in from. |
size | string | undefined | themeSpacing(80) (left/right), themeSpacing(64) (top/bottom) | CSS length for the drawer's width (left/right/start/end) or height (top/bottom). |
"start"and"end"are RTL-aware logical placements resolved at mount time fromdocument.dir:"start"→ left (LTR) / right (RTL);"end"→ right (LTR) / left (RTL).
Customization
Must see the source of patch at the bottom of each patch page to understand the structure then code it still code as html native element.
There are four levels of customization, in increasing order of effort:
- Patch props. Each patch exposes a small, stable set of props—typically fewer than five. Lowest friction.
- Context attributes. Use
dataTone,dataSize, anddataDensityon a container to shift tone, size, or density for an entire subtree without touching individual elements. - Inline override. Native-wins merge strategy: any property set directly on the element overrides the patch value.
- Create a variant. Clone a similar patch and edit it. Use this only when you need a reusable custom version.
Formulas
Unit - U = fontSize / 4 - convert final values with themeSpacing(n).
Size - n = intrinsic text lines, w = wrapping level, d = density factor:
height = (n * 6 + 2 * d * w) * U
paddingBlock = d * w * U
paddingInline = ceil(3 / w) * d * w * U
radius = d * w * UBase density d = 1.5:
| U | w=0 | w=1 | w=2 | w=3 |
|---|---|---|---|---|
height (n = 1) | 6 | 9 | 12 | 15 |
| paddingBlock | 0 | 1.5 | 3 | 4.5 |
| paddingInline | 3 | 4.5 | 6 | 4.5 |
| radius | 0 | 1.5 | 3 | 4.5 |
Tone - K = N / 2 where N is the palette length. For N = 18, K = 9.
| Role | Shift | n=0 |
|---|---|---|
| Background | parent +/- n | 0 |
| Text | bg + K | 6 |
| Border | bg + K/2 | 3 |
| Hover | bg + 2K/3 | 4 |
| Selected / Focus | above +/- K/3 | 2-4 |
State shift range: K/3 <= delta <= 2K/3.
import {
type BehaviorInstance,
behavior,
type ElementNode,
type PartialElement,
type ReadableState,
type ValueOrState,
} from "@domphy/core";
import {
type ThemeColor,
themeColor,
themeDensity,
themeSize,
themeSpacing,
} from "@domphy/theme";
import { elevation } from "../utils/elevation.js";
import { hasOpenFloatingPanel } from "../utils/floating.js";
import { asOpenState, dismissOpen, subscribeOpen } from "../utils/openState.js";
import { lockScroll, unlockScroll } from "../utils/scrollLock.js";
type PhysicalPlacement = "left" | "right" | "top" | "bottom";
type Placement = PhysicalPlacement | "start" | "end";
const translateOut: Record<PhysicalPlacement, string> = {
left: "translateX(-100%)",
right: "translateX(100%)",
top: "translateY(-100%)",
bottom: "translateY(100%)",
};
const marginMap: Record<PhysicalPlacement, string> = {
left: "0 auto 0 0",
right: "0 0 0 auto",
top: "0 0 auto 0",
bottom: "auto 0 0 0",
};
const isVertical = (p: PhysicalPlacement) => p === "left" || p === "right";
function resolvePhysical(
placement: Placement,
isRTL: boolean,
): PhysicalPlacement {
if (placement === "start") return isRTL ? "right" : "left";
if (placement === "end") return isRTL ? "left" : "right";
return placement;
}
type DrawerProps = {
state: ReadableState<boolean>;
// Lives on the behavior instance so a reused node gets the latest callback
// via update() — do not close over factory-generation onDismiss.
onDismiss?: () => void;
placement: Placement;
size?: string;
};
type DrawerInstance = BehaviorInstance<DrawerProps> & {
requestClose: () => void;
};
function attachDrawer(
node: ElementNode,
initialProps: DrawerProps,
): DrawerInstance {
let { state, onDismiss, placement, size } = initialProps;
const dlg = node.domElement as HTMLDialogElement;
dlg.setAttribute("aria-modal", "true");
const onCancel = (e: Event) => {
e.preventDefault();
// Top-layer-only dismissal: an open popover/select/datePicker inside this
// drawer owns the Escape (see hasOpenFloatingPanel).
if (hasOpenFloatingPanel(dlg)) return;
dismissOpen(state, onDismiss);
};
dlg.addEventListener("cancel", onCancel);
const resolve = (nextPlacement: Placement, nextSize?: string) => {
const isLogical = nextPlacement === "start" || nextPlacement === "end";
const isRTL =
isLogical &&
(dlg.ownerDocument.documentElement.dir === "rtl" ||
dlg.ownerDocument.dir === "rtl");
const physical = resolvePhysical(nextPlacement, isRTL);
const drawerSize =
nextSize ?? (isVertical(physical) ? themeSpacing(80) : themeSpacing(64));
dlg.style.margin = marginMap[physical];
dlg.style.width = isVertical(physical) ? drawerSize : "100dvw";
dlg.style.height = isVertical(physical) ? "100dvh" : drawerSize;
return physical;
};
let physical = resolve(placement, size);
let closing = false;
let scrollLocked = false;
let closeTimer: ReturnType<typeof setTimeout> | null = null;
const finishClose = () => {
closeTimer = null;
if (!closing) return;
closing = false;
// Guard for environments with HTMLDialogElement but no close()
// implementation (e.g. jsdom in tests).
if (typeof dlg.close === "function") dlg.close();
// Same fix as dialog.ts: the closed state was only ever represented
// by an off-screen `transform`, never visibility/pointer-events/display
// — a closed drawer stayed fully reachable by Tab and exposed to the
// accessibility tree (a CSS transform, like opacity, does neither),
// and a consumer's own `style: { display: ... }` overrides the UA
// stylesheet's `dialog:not([open])` rule anyway. Set INLINE so it
// always wins. Do not put `display: none` on the patch CSS class —
// native `style.display` would lose to that class on reopen.
dlg.style.visibility = "hidden";
dlg.style.pointerEvents = "none";
dlg.style.display = "none";
if (scrollLocked) {
unlockScroll();
scrollLocked = false;
}
};
const onTransitionEnd = (e: Event) => {
if (e.target !== dlg) return;
if ((e as TransitionEvent).propertyName !== "transform") return;
finishClose();
};
dlg.addEventListener("transitionend", onTransitionEnd);
const update = (val: boolean) => {
if (val) {
// Cancel any in-flight close: a pending fallback timer or a leftover
// `closing` flag would otherwise finalize-close the just-opened drawer
// when the open slide's own transitionend (or the 350ms timer) fires.
closing = false;
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
dlg.style.visibility = "visible";
dlg.style.pointerEvents = "auto";
dlg.style.display = "";
// Guard for environments with HTMLDialogElement but no showModal()
// implementation (e.g. jsdom in tests). Also guard against re-entering
// on an already-open dialog — showModal() throws InvalidStateError in
// real browsers when the dialog is already open.
if (typeof dlg.showModal === "function" && !dlg.open) dlg.showModal();
if (!scrollLocked) {
lockScroll();
scrollLocked = true;
}
requestAnimationFrame(() => {
dlg.style.transform = "translate(0, 0)";
});
} else {
closing = true;
dlg.style.transform = translateOut[physical];
if (!dlg.open) {
finishClose();
return;
}
closeTimer = setTimeout(finishClose, 350);
}
};
let release = subscribeOpen(state, update);
return {
requestClose: () => dismissOpen(state, onDismiss),
update(props) {
onDismiss = props.onDismiss;
if (props.state !== state) {
release();
state = props.state;
release = subscribeOpen(state, update, false);
}
if (props.placement !== placement || props.size !== size) {
placement = props.placement;
size = props.size;
physical = resolve(placement, size);
if (!state.get()) dlg.style.transform = translateOut[physical];
}
},
destroy() {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
release();
dlg.removeEventListener("cancel", onCancel);
dlg.removeEventListener("transitionend", onTransitionEnd);
if (scrollLocked) {
unlockScroll();
scrollLocked = false;
}
},
};
}
/**
* Edge-anchored modal drawer driven by an `open` State. Slides in/out from a
* chosen edge via a 250 ms transform transition, calls `showModal()`/`close()`,
* locks page scroll while open, and closes on backdrop click. A 350 ms fallback
* ensures `close()` is always called even when `transitionend` doesn't fire
* (reduced-motion, `display:none`, detached element). Apply to a `<dialog>`.
*
* Because the patch uses the native `<dialog>` `showModal()` API, the browser
* traps focus inside the drawer while it is open and restores focus to the
* previously focused element when `close()` is called. Sets `aria-modal="true"`.
* Escape key closes the drawer via the animated state path (not immediate close).
*
* `"start"` and `"end"` placements resolve to left/right based on the
* document's `dir` attribute at mount time, enabling RTL-aware drawers:
* `"start"` → left (LTR) / right (RTL); `"end"` → right (LTR) / left (RTL).
*
* @hostTag dialog
* @param props.color - Theme color tone for the drawer surface. Defaults to "neutral".
* @param props.open - Open state (`ValueOrState<boolean>`), including `Computed`/`ReadableState`. When the source is read-only, pass `onDismiss` so Escape/backdrop can close. Defaults to false.
* @param props.onDismiss - Called when Escape/backdrop/`requestClose` request close. Optional. Required to close when `open` is a read-only `Computed`/`ReadableState`.
* @param props.placement - Edge to anchor to. "left" | "right" | "top" | "bottom" | "start" | "end". Defaults to "end".
* @param props.size - CSS length for the drawer's width (left/right/start/end) or height (top/bottom). Defaults to themeSpacing(80) for left/right, themeSpacing(64) for top/bottom.
* @example { dialog: [...], $: [drawer({ open, placement: "start" })] }
*/
function drawer(
props: {
color?: ThemeColor;
open?: ValueOrState<boolean>;
onDismiss?: () => void;
placement?: Placement;
size?: string;
} = {},
): PartialElement {
const {
color = "neutral",
open = false,
onDismiss,
placement = "end",
size,
} = props;
const state = asOpenState(open);
// For static rendering / SSR assume LTR as fallback; corrected at mount time.
const physicalFallback = resolvePhysical(placement, false);
const defaultSize = isVertical(physicalFallback)
? themeSpacing(80)
: themeSpacing(64);
const drawerSize = size ?? defaultSize;
return {
_onInsert: (node) => {
if (node.tagName !== "dialog") {
console.warn(`"drawer" patch must use dialog tag`);
}
},
...behavior<DrawerProps>("drawer", attachDrawer, {
state,
onDismiss,
placement,
size,
}),
onClick: (e: MouseEvent, node) => {
if (e.target !== node.domElement) return;
const r = node.domElement!.getBoundingClientRect();
const inside =
e.clientX >= r.left &&
e.clientX <= r.right &&
e.clientY >= r.top &&
e.clientY <= r.bottom;
if (!inside) node.getBehavior<DrawerInstance>("drawer")?.requestClose();
},
style: {
transform: translateOut[physicalFallback],
// Matches finishClose's inline defaults — a drawer that mounts
// already-closed (the common case) must start out of the tab
// order/accessibility tree from first paint, not just after its first
// open->close cycle runs finishClose.
visibility: "hidden",
pointerEvents: "none",
transition: "transform 0.25s ease",
fontSize: (listener) => themeSize(listener, "inherit"),
color: (listener) => themeColor(listener, "shift-10", color),
backgroundColor: (listener) => themeColor(listener, "inherit", color),
border: "none",
padding: (listener) => themeSpacing(themeDensity(listener) * 3),
margin: marginMap[physicalFallback],
width: isVertical(physicalFallback) ? drawerSize : "100dvw",
height: isVertical(physicalFallback) ? "100dvh" : drawerSize,
maxWidth: "100dvw",
maxHeight: "100dvh",
boxShadow: elevation("high"),
"&::backdrop": {
backgroundColor: (listener) =>
themeColor(listener, "shift-2", "neutral"),
opacity: 0.75,
},
},
};
}
export { drawer };