Skip to main content

Breaking: @trackunit/react-drawer adds useDrawer, DrawerHeader, and variants

@trackunit/react-drawer has been reworked to align with Sheet and Modal: a new useDrawer hook owns state and dismiss, a new DrawerHeader component provides the standard toolbar, and the panel now has explicit variant="default" | "modal" semantics. Legacy inline props on <Drawer /> (open, onClose, onOpen, hasOverlay, keepMountedWhenClosed) and the DrawerToggle component have been removed.

New exports

useDrawer hook

Owns open/close state, dismiss handling, and Floating UI wiring for a Drawer. Consumers call useDrawer() and spread its return value onto <Drawer /> — the same shape as useSheet / useModal.

import { Drawer, DrawerHeader, useDrawer } from "@trackunit/react-drawer";

const drawer = useDrawer({ position: "right", variant: "modal" });

<>
<Button onClick={drawer.open}>Open</Button>
<Drawer {...drawer} ariaLabel="Filters">
<DrawerHeader onClickClose={drawer.close} />
<div className="p-4"></div>
</Drawer>
</>

Supports controlled (isOpen) and uncontrolled (defaultOpen) modes, stable open / close / toggle / requestClose identities, and an onBeforeClose guard (sync or async — return false or a Promise<false> to keep the drawer open).

DrawerHeader component

Standard drawer toolbar with configurable affordances:

  • onClickClose — renders the built-in close (X) button.
  • onClickBack / onClickForward — navigation arrows.
  • menuContent — kebab overflow menu, typically a <MenuContent> with <MenuItem> children.
  • hideCloseButton — for read-only drawers, or drawers whose parent already owns the close affordance.

Button titles come from the library's translation namespace and are not overridable — the affordances are universal ("Close", "Back", "Forward", "More actions").

<Drawer {...drawer}>
<DrawerHeader
menuContent={
<MenuContent>
<MenuItem id="edit" label="Edit" prefix={<Icon name="PencilSquare" size="small" />} />
</MenuContent>
}
onClickBack={goBack}
onClickClose={drawer.close}
/>
{/* body */}
</Drawer>

Wire the X to useDrawer's close so it shares the same dismiss pipeline (Escape, outside-press, onBeforeClose guard) as the rest of the drawer.

New types

  • UseDrawerProps
  • UseDrawerReturnValue
  • DrawerVariant"default" | "modal"
  • DrawerDismissOptions
  • DrawerFloatingUiProps
  • DrawerHeaderProps

useDrawer reuses the shared overlay-dismissible types from @trackunit/react-components (UseOverlayDismissibleProps, OnCloseFn, OnBeforeCloseFn, DismissOptions, CloseReason) so the API matches useSheet and useModal.

New variant prop

Drawer now exposes a semantic variant that controls backdrop, focus trap, and dialog ARIA:

  • "default" — no backdrop, no focus trap, role="complementary". The surrounding page stays interactive. ESC still calls onClose when provided; outside-press does not close the drawer.
  • "modal" — dimming backdrop, role="dialog" + aria-modal, focus trap that returns focus to the trigger on close, and outside-press dismiss.

The focus trap on variant="modal" can be opted out with trapFocus: false for cases where a parent already manages focus for the drawer's subtree. trapFocus is ignored when variant="default" — the default variant never traps focus.

New accessibility props

Drawer now accepts either ariaLabel or ariaLabelledBy. Prefer ariaLabelledBy when the drawer body renders a visible heading so the visible label and the accessible name stay in sync.

<Drawer {...drawer} ariaLabelledBy="asset-inspector-title">
<h2 id="asset-inspector-title">{selectedAsset.name}</h2>
{/* … */}
</Drawer>

Without either prop, assistive technology has no accessible name for the panel and will announce it as an unnamed dialog/region.

Breaking changes

DrawerToggle removed

DrawerToggle is no longer exported from @trackunit/react-drawer. Call useDrawer's open / close / toggle from a trigger of your choice.

Before:

<DrawerToggle open={isOpen} onToggle={setIsOpen} />

After:

const drawer = useDrawer();
<Button onClick={drawer.toggle}>Toggle</Button>

<Drawer /> inline state props replaced by useDrawer return value

The following inline props on <Drawer /> have been removed:

  • open
  • onClose
  • onOpen
  • hasOverlay
  • keepMountedWhenClosed
  • ref

State and dismiss are now owned by useDrawer and spread onto the component.

Before:

const [isOpen, setIsOpen] = useState(false);

<Drawer hasOverlay onClose={() => setIsOpen(false)} open={isOpen} position="right">
{children}
</Drawer>

After:

const drawer = useDrawer({ position: "right", variant: "modal" });

<Drawer {...drawer}>
{children}
</Drawer>

The old hasOverlay toggle is now expressed via variant: variant="modal" renders the dim backdrop, variant="default" renders no backdrop and leaves the surrounding page interactive.

DrawerPosition narrowed to "left" / "right"

DrawerPosition no longer accepts "top" or "bottom". Use Sheet from @trackunit/react-components for bottom-anchored panels.

Panel width standardized

The drawer panel now renders at w-full sm:w-[28rem] max-w-[100dvw] by default. On mobile viewports the drawer fills the screen; on sm and wider it is 28 rem (448 px).

Consumers that need a different width should pass an sm:-or-wider-prefixed class via className. Unprefixed w-* classes are collapsed by twMerge and do not override the responsive base:

<Drawer {...drawer} className="sm:w-[720px]">
{children}
</Drawer>

Motion and dismiss now built on Floating UI

Modal drawers use @floating-ui/react for outside-press dismiss and focus-trap management (FloatingFocusManager). ESC and outside-press behaviour is configurable via useDrawer({ dismiss }), mirroring useSheet / useModal. Focus is moved into the panel after the enter transition settles, and the drawer no longer steals focus from a control the user has already interacted with during the slide-in.

Migration

Every drawer consumer in @trackunit/manager has been migrated in this release. External consumers should:

  1. Replace open / onClose state with a useDrawer() call and spread the result onto <Drawer />.
  2. Choose a variant explicitly — "modal" matches the previous hasOverlay default; "default" matches hasOverlay={false}.
  3. If a close affordance was hand-rolled inside the drawer body, replace it with <DrawerHeader onClickClose={drawer.close} /> for the standard toolbar.
  4. Drop any usage of DrawerToggle; call drawer.open / drawer.close / drawer.toggle from a trigger of your choice.
  5. Add an ariaLabel or ariaLabelledBy to <Drawer /> for an accessible name.

If the drawer wraps a form or content with unsaved changes, use onBeforeClose to guard the close:

const drawer = useDrawer({
onBeforeClose: async () => (await confirmDiscard()) === "discard",
});