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.
registry/behivetech/atoms/drawer at v0.4.9, snapshotted 2026-11-10
drawer.tsx
1import { type HTMLAttributes, type ReactNode } from "react";2import * as RadixDialog from "@radix-ui/react-dialog";3import { getClassName } from "@bht-component-registry/class-names";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 it11// here would pull all 22 icons into every consumer of Drawer.12import { CloseIcon } from "@behivetech/atoms.icon";13import styles from "./drawer.module.scss";1415export type DrawerPlacement = "top" | "right" | "bottom" | "left";1617export 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 with37 * it, and closes the drawer when clicked — a real modal. Defaults to true. Set38 * false for a non-blocking panel that floats alongside content the user can keep39 * interacting with, e.g. an always-visible properties panel that updates as a40 * different item is selected behind it.41 */42 backdrop?: boolean;43 /** Panel content */44 children: ReactNode;45}4647/**48 * A `Card` docked to one edge of the viewport as a slide-in panel, built on49 * 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 it51 * blocks the rest of the page or floats alongside content the user can keep52 * 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 ...rest65}: DrawerProps) => {66 const [rootClass, getChildClass] = getClassName({67 className,68 rootClass: "BHT__Drawer",69 modifiers: { [`placement-${placement}`]: true },70 styles,71 });7273 const hasHeader = title !== undefined;7475 const cardTitle = hasHeader ? (76 <RadixDialog.Title asChild>77 <span>{title}</span>78 </RadixDialog.Title>79 ) : undefined;8081 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;8889 return (90 <RadixDialog.Root91 open92 modal={backdrop}93 onOpenChange={(open) => !open && onClose()}94 >95 <RadixDialog.Portal>96 {backdrop && (97 <RadixDialog.Overlay className={getChildClass("backdrop")} />98 )}99 <RadixDialog.Content100 {...rest}101 className={rootClass}102 onPointerDownOutside={(e) => {103 if (!backdrop) e.preventDefault();104 }}105 >106 <Card107 className={getChildClass("panel")}108 icon={hasHeader ? icon : undefined}109 title={cardTitle}110 headingLevel={headingLevel}111 action={cardAction}112 padding={padding}113 scrollableBody114 >115 {!hasHeader && (116 <RadixDialog.Title117 className={getChildClass("visually-hidden-title")}118 >119 Dialog120 </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 a2// Card too, and Card's CSS otherwise never ships with this package.3@use "@behivetech/atoms.card/card.module.scss";45// 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);910 &__backdrop {11 position: fixed;12 inset: 0;13 background: var(--color-overlay);14 z-index: var(--z-modal, 400);15 }1617 &__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);2425 box-shadow: var(--md-sys-elevation-1, var(--shadow-sm));26 }2728 &__visually-hidden-title {29 // Fixed px: this is the standard clip idiom for hiding content from sight30 // 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 }4142 &--placement-right,43 &--placement-left {44 &.BHT__Drawer {45 position: fixed;46 top: 0;47 height: 100vh;48 }4950 .BHT__Drawer {51 &__panel {52 width: min(20rem, 90vw);53 height: 100%;54 --card-radius: 0;55 }56 }57 }5859 &--placement-right {60 &.BHT__Drawer {61 right: 0;62 }63 }6465 &--placement-left {66 &.BHT__Drawer {67 left: 0;68 }69 }7071 &--placement-top,72 &--placement-bottom {73 &.BHT__Drawer {74 position: fixed;75 left: 0;76 width: 100vw;77 }7879 .BHT__Drawer {80 &__panel {81 width: 100%;82 max-height: min(20rem, 90vh);83 --card-radius: 0;84 }85 }86 }8788 &--placement-top {89 &.BHT__Drawer {90 top: 0;91 }92 }9394 &--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";23export const BasicDrawer = () => (4 <Drawer onClose={() => {}} title="Add Block" placement="right">5 Panel content6 </Drawer>7);89export const DrawerWithoutTitle = () => (10 <Drawer onClose={() => {}} placement="right">11 Panel content, no header12 </Drawer>13);1415export const NonBlockingRightPanelDrawer = () => (16 <Drawer onClose={() => {}} title="Block" placement="right" backdrop={false}>17 Non-blocking side panel content18 </Drawer>19);2021export const BottomSheetDrawer = () => (22 <Drawer onClose={() => {}} title="Filters" placement="bottom">23 Slides up from the bottom edge24 </Drawer>25);