react ~5 KB 0 deps v0.2.1 β†— GitHub β†—

@mshafiqyajid/react-drawer

Headless hook and styled navigation drawer for React. Left or right side, three width sizes, push or overlay variant, swipe to close, focus trap, body scroll lock, Escape to close, and smooth slide animations. Zero dependencies.

Playground #

Props
TSX
import { DrawerStyled } from "@mshafiqyajid/react-drawer/styled";
import "@mshafiqyajid/react-drawer/styles.css";

<DrawerStyled
  open={open}
  onOpenChange={setOpen}
  showTitle
/>

Install #

npm install @mshafiqyajid/react-drawer

Quick start #

import { useState } from "react";
import { DrawerStyled } from "@mshafiqyajid/react-drawer/styled";
import "@mshafiqyajid/react-drawer/styles.css";

const [open, setOpen] = useState(false);

<button onClick={() => setOpen(true)}>Open drawer</button>

<DrawerStyled
  open={open}
  onOpenChange={setOpen}
  title="Navigation"
>
  <nav>
    <a href="/">Home</a>
    <a href="/about">About</a>
  </nav>
</DrawerStyled>

Side #

Set side to "left" (default) or "right" to control which edge the drawer slides in from.

<DrawerStyled open={open} onOpenChange={setOpen} side="right" title="Settings">
  <p>Slides in from the right.</p>
</DrawerStyled>

Width sizes #

size controls the panel width. "sm" is 240 px, "md" is 280 px (default), "lg" is 320 px. Pass a width prop for a fully custom size.

<DrawerStyled open={open} onOpenChange={setOpen} size="sm">…</DrawerStyled>
<DrawerStyled open={open} onOpenChange={setOpen} size="md">…</DrawerStyled>
<DrawerStyled open={open} onOpenChange={setOpen} size="lg">…</DrawerStyled>

{/* Custom width β€” overrides the size preset */}
<DrawerStyled open={open} onOpenChange={setOpen} width={400}>…</DrawerStyled>
<DrawerStyled open={open} onOpenChange={setOpen} width="50vw">…</DrawerStyled>

Push variant #

By default the drawer slides over the page with a dimming backdrop (variant="overlay"). Set variant="push" to have the drawer push the page content aside instead. There is no backdrop in push mode β€” the drawer sits fixed on the edge and the page shifts using the --rdrw-push-offset CSS variable that is automatically set on :root.

<DrawerStyled open={open} onOpenChange={setOpen} variant="push" title="Sidebar">
  <nav>…</nav>
</DrawerStyled>

Apply the offset to your layout container:

/* Example: shift your main content when the push drawer is open */
.layout-main {
  margin-left: var(--rdrw-push-offset, 0px);
  transition: margin-left 300ms cubic-bezier(0.32, 0.72, 0, 1);
}

Swipe to close #

On touch and pointer devices, users can swipe the panel toward the closed edge to dismiss it. This is enabled by default (swipeable={true}). The drawer follows the finger during drag and closes if the swipe exceeds 80 px. Disable it by passing swipeable={false}.

{/* Disable swipe to close */}
<DrawerStyled open={open} onOpenChange={setOpen} swipeable={false}>
  …
</DrawerStyled>

Close button #

A close button (X icon) is rendered inside the header area by default. Hide it with showCloseButton={false}. When no title is provided, the header is only rendered if showCloseButton is true.

{/* Header visible only for the close button β€” no title */}
<DrawerStyled open={open} onOpenChange={setOpen} showCloseButton>
  <p>Content here.</p>
</DrawerStyled>

{/* Hide close button entirely */}
<DrawerStyled open={open} onOpenChange={setOpen} title="Nav" showCloseButton={false}>
  <p>Content here.</p>
</DrawerStyled>

Keep mounted #

By default the drawer's portal is removed from the DOM when closed. Set keepMounted to keep the portal in the DOM (hidden via aria-hidden and inert) so that scroll position and form state inside the drawer are preserved across open/close cycles.

<DrawerStyled open={open} onOpenChange={setOpen} keepMounted title="Cart">
  {/* Scroll position is remembered between open/close */}
  <CartContents />
</DrawerStyled>

Focus management #

Focus is trapped inside the drawer while open and returned to the previously-focused element on close. Override with initialFocusRef and finalFocusRef.

const inputRef = useRef<HTMLInputElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);

<button ref={triggerRef} onClick={() => setOpen(true)}>Open</button>

<DrawerStyled
  open={open}
  onOpenChange={setOpen}
  initialFocusRef={inputRef}
  finalFocusRef={triggerRef}
>
  <input ref={inputRef} placeholder="Focused on open" />
</DrawerStyled>

Headless #

import { useDrawer } from "@mshafiqyajid/react-drawer";

const { isOpen, open, close, drawerProps, overlayProps, triggerProps } = useDrawer({
  closeOnEsc: true,
  lockBodyScroll: true,
});

<button {...triggerProps} onClick={open}>Open</button>

{isOpen && (
  <div
    {...overlayProps}
    style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)" }}
    onClick={close}
  >
    <div
      {...drawerProps}
      style={{
        position: "fixed",
        top: 0, left: 0, bottom: 0,
        width: 280,
        background: "#fff",
        padding: "1rem",
      }}
      onClick={(e) => e.stopPropagation()}
    >
      <button onClick={close}>Close</button>
      <nav>…</nav>
    </div>
  </div>
)}

CSS tokens #

:root {
  --rdrw-width-sm: 240px;
  --rdrw-width-md: 280px;
  --rdrw-width-lg: 320px;
  --rdrw-bg: #ffffff;
  --rdrw-border: #e4e4e7;
  --rdrw-overlay-bg: rgba(0, 0, 0, 0.45);
  --rdrw-fg: #18181b;
  --rdrw-duration: 300ms;
  --rdrw-ease: cubic-bezier(0.32, 0.72, 0, 1);
  --rdrw-overlay-duration: 200ms;
}

API #

PropTypeDefaultDescription
openbooleanβ€”Controlled open state
defaultOpenbooleanfalseUncontrolled default open state
onOpenChange(open: boolean) => voidβ€”Called when open state changes
side"left" | "right""left"Which edge the drawer slides from
size"sm" | "md" | "lg""md"Panel width β€” 240 / 280 / 320 px
variant"overlay" | "push""overlay"Overlay dims the page; push shifts page content aside via --rdrw-push-offset
widthstring | numberβ€”Custom panel width β€” overrides the size preset (sets --rdrw-custom-width)
showCloseButtonbooleantrueRender an X close button in the header area
swipeablebooleantrueAllow swipe-to-close gesture on touch / pointer devices
keepMountedbooleanfalseKeep portal in DOM when closed (hidden via aria-hidden + inert)
closeOnOverlayClickbooleantrueClose when clicking the overlay backdrop
closeOnEscbooleantrueClose on Escape key
lockBodyScrollbooleantrueLock body scroll while drawer is open
titleReactNodeβ€”Drawer heading β€” omit to hide the title (header still renders if showCloseButton)
descriptionReactNodeβ€”Accessible description linked via aria-describedby
footerReactNodeβ€”Footer content β€” omit to hide footer
childrenReactNodeβ€”Scrollable body content
initialFocusRefRefObject<HTMLElement>β€”Element to focus when drawer opens
finalFocusRefRefObject<HTMLElement>β€”Element to focus when drawer closes
classNamestringβ€”Extra class on the panel element
styleCSSPropertiesβ€”Inline style on the panel element
refforwardedβ€”Forwarded to the panel div

Data attributes

AttributeValuesDescription
data-side"left" | "right"Current side β€” on both root and panel
data-state"open" | "closed"Visibility state β€” on both root and panel
data-size"sm" | "md" | "lg"Current size β€” on panel
data-variant"overlay" | "push"Current variant β€” on both root and panel
data-dragging"true" | absentPresent on panel while swipe drag is active

useDrawer options & return

KeyTypeDescription
openbooleanControlled open state
defaultOpenbooleanUncontrolled default (false)
onOpenChange(open: boolean) => voidOpen change callback
closeOnEscbooleanClose on Escape (true)
lockBodyScrollbooleanLock body scroll (true)
variant"overlay" | "push"Overlay or push mode ("overlay")
Returns:
isOpenbooleanCurrent open state
open() => voidOpen the drawer
close() => voidClose the drawer
toggle() => voidToggle open/closed
dragOffsetnumberCurrent swipe drag offset in px (0 when not dragging)
isDraggingbooleanWhether a swipe drag is in progress
drawerPropsDrawerPropsSpread onto panel element (role, aria-*, tabIndex, id)
overlayPropsOverlayPropsSpread onto overlay element (data-rdrw-overlay, aria-hidden)
triggerPropsTriggerPropsSpread onto trigger button (aria-haspopup, aria-expanded, aria-controls)
Edit this page on GitHub