A `Card` docked to one edge of the viewport as a slide-in panel, built on [Radix `Dialog`](https://www.radix-ui.com/primitives/docs/components/dialog) for focus trapping, Escape-to-close, and outside-click-to-close. It has no opinion on what goes inside the panel — an optional `title` renders a header with a close button, otherwise just the `children` are shown.

pnpm add @behivetech/atoms.drawer

This is the snapshot of v0.4.10, the current version, as recorded on 2026-11-10. The live page runs its examples →

registry/behivetech/atoms/drawer at v0.4.10, snapshotted 2026-11-10

drawer.tsx
1import { type HTMLAttributes, type ReactNode } from "react";
2import * as RadixDialog from "@radix-ui/react-dialog";
3import { getClassName } from "@behivetech/get-class-name";
4import {
5 Card,
6 type CardPadding,
7 type CardHeadingLevel,
8} from "@behivetech/atoms.card";
9import { Button } from "@behivetech/atoms.button";
10// The glyph, not the set: `<Icon name>` is a runtime lookup, so importing it
11// here would pull all 22 icons into every consumer of Drawer.
12import { CloseIcon } from "@behivetech/icons.close";
13import styles from "./drawer.module.scss";
14
15export type DrawerPlacement = "top" | "right" | "bottom" | "left";
16
17export interface DrawerProps extends Omit<
18 HTMLAttributes<HTMLDivElement>,
19 "title"
20> {
21 /** Additional class names to merge with the component root element */
22 className?: string;
23 /** Called when the drawer is dismissed (backdrop click, Escape, or the close button) */
24 onClose: () => void;
25 /** Optional header title; omit to render no header and no close button */
26 title?: string;
27 /** Optional icon rendered before the title in the header; ignored if `title` is omitted */
28 icon?: ReactNode;
29 /** Internal padding of the panel body — same values as `Card`'s `padding` prop */
30 padding?: CardPadding;
31 /** Which heading tag wraps `title` — same values as `Card`'s `headingLevel` prop */
32 headingLevel?: CardHeadingLevel;
33 /** Which edge the panel slides in from */
34 placement: DrawerPlacement;
35 /**
36 * Whether a dimmed backdrop covers the rest of the page, blocks interaction with
37 * it, and closes the drawer when clicked — a real modal. Defaults to true. Set
38 * false for a non-blocking panel that floats alongside content the user can keep
39 * interacting with, e.g. an always-visible properties panel that updates as a
40 * different item is selected behind it.
41 */
42 backdrop?: boolean;
43 /** Panel content */
44 children: ReactNode;
45}
46
47/**
48 * A `Card` docked to one edge of the viewport as a slide-in panel, built on
49 * Radix's `Dialog` for focus trapping, Escape-to-close, and outside-click-to-close.
50 * `placement` controls which edge it docks to; `backdrop` controls whether it
51 * blocks the rest of the page or floats alongside content the user can keep
52 * interacting with. For a centered dialog that always blocks the page, use `Modal`.
53 */
54export const Drawer = ({
55 className,
56 onClose,
57 title,
58 icon,
59 padding,
60 headingLevel,
61 placement,
62 backdrop = true,
63 children,
64 ...rest
65}: DrawerProps) => {
66 const [rootClass, getChildClass] = getClassName({
67 className,
68 rootClass: "BHT__Drawer",
69 modifiers: { [`placement-${placement}`]: true },
70 styles,
71 });
72
73 const hasHeader = title !== undefined;
74
75 const cardTitle = hasHeader ? (
76 <RadixDialog.Title asChild>
77 <span>{title}</span>
78 </RadixDialog.Title>
79 ) : undefined;
80
81 const cardAction = hasHeader ? (
82 <RadixDialog.Close asChild>
83 <Button variant="ghost" size="sm" iconOnly aria-label="Close">
84 <CloseIcon size="sm" />
85 </Button>
86 </RadixDialog.Close>
87 ) : undefined;
88
89 return (
90 <RadixDialog.Root
91 open
92 modal={backdrop}
93 onOpenChange={(open) => !open && onClose()}
94 >
95 <RadixDialog.Portal>
96 {backdrop && (
97 <RadixDialog.Overlay className={getChildClass("backdrop")} />
98 )}
99 <RadixDialog.Content
100 {...rest}
101 className={rootClass}
102 onPointerDownOutside={(e) => {
103 if (!backdrop) e.preventDefault();
104 }}
105 >
106 <Card
107 className={getChildClass("panel")}
108 icon={hasHeader ? icon : undefined}
109 title={cardTitle}
110 headingLevel={headingLevel}
111 action={cardAction}
112 padding={padding}
113 scrollableBody
114 >
115 {!hasHeader && (
116 <RadixDialog.Title
117 className={getChildClass("visually-hidden-title")}
118 >
119 Dialog
120 </RadixDialog.Title>
121 )}
122 {children}
123 </Card>
124 </RadixDialog.Content>
125 </RadixDialog.Portal>
126 </RadixDialog.Root>
127 );
128};
drawer.module.scss
1// See modal.module.scss for why this is here — Drawer renders its panel as a
2// Card too, and Card's CSS otherwise never ships with this package.
3@use "@behivetech/atoms.card/card.module.scss";
4
5// Material Design 3 modal side sheet (bottom sheet for top/bottom placement).
6// The panel is a Card re-themed through its custom properties.
7.BHT__Drawer {
8 z-index: var(--z-modal, 400);
9
10 &__backdrop {
11 position: fixed;
12 inset: 0;
13 background: var(--color-overlay);
14 z-index: var(--z-modal, 400);
15 }
16
17 &__panel {
18 /* Card reads these custom properties — see Card's README */
19 --card-background: var(
20 --md-sys-color-surface-container-low,
21 var(--color-surface-raised)
22 );
23 --card-radius: var(--md-sys-shape-corner-large, 16px);
24
25 box-shadow: var(--md-sys-elevation-1, var(--shadow-sm));
26 }
27
28 &__visually-hidden-title {
29 // Fixed px: this is the standard clip idiom for hiding content from sight
30 // while leaving it to screen readers, not a size that should scale.
31 position: absolute;
32 width: 1px;
33 height: 1px;
34 padding: 0;
35 margin: -1px;
36 overflow: hidden;
37 clip: rect(0, 0, 0, 0);
38 white-space: nowrap;
39 border: 0;
40 }
41
42 &--placement-right,
43 &--placement-left {
44 &.BHT__Drawer {
45 position: fixed;
46 top: 0;
47 height: 100vh;
48 }
49
50 .BHT__Drawer {
51 &__panel {
52 width: min(20rem, 90vw);
53 height: 100%;
54 --card-radius: 0;
55 }
56 }
57 }
58
59 &--placement-right {
60 &.BHT__Drawer {
61 right: 0;
62 }
63 }
64
65 &--placement-left {
66 &.BHT__Drawer {
67 left: 0;
68 }
69 }
70
71 &--placement-top,
72 &--placement-bottom {
73 &.BHT__Drawer {
74 position: fixed;
75 left: 0;
76 width: 100vw;
77 }
78
79 .BHT__Drawer {
80 &__panel {
81 width: 100%;
82 max-height: min(20rem, 90vh);
83 --card-radius: 0;
84 }
85 }
86 }
87
88 &--placement-top {
89 &.BHT__Drawer {
90 top: 0;
91 }
92 }
93
94 &--placement-bottom {
95 &.BHT__Drawer {
96 bottom: 0;
97 }
98 }
99}
index.ts
1export { Drawer } from "./drawer.js";
2export type { DrawerProps, DrawerPlacement } from "./drawer.js";
drawer.example.tsx
1import { Drawer } from "./drawer.js";
2
3export const BasicDrawer = () => (
4 <Drawer onClose={() => {}} title="Add Block" placement="right">
5 Panel content
6 </Drawer>
7);
8
9export const DrawerWithoutTitle = () => (
10 <Drawer onClose={() => {}} placement="right">
11 Panel content, no header
12 </Drawer>
13);
14
15export const NonBlockingRightPanelDrawer = () => (
16 <Drawer onClose={() => {}} title="Block" placement="right" backdrop={false}>
17 Non-blocking side panel content
18 </Drawer>
19);
20
21export const BottomSheetDrawer = () => (
22 <Drawer onClose={() => {}} title="Filters" placement="bottom">
23 Slides up from the bottom edge
24 </Drawer>
25);