@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 #
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 #
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | β | Controlled open state |
| defaultOpen | boolean | false | Uncontrolled 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 |
| width | string | number | β | Custom panel width β overrides the size preset (sets --rdrw-custom-width) |
| showCloseButton | boolean | true | Render an X close button in the header area |
| swipeable | boolean | true | Allow swipe-to-close gesture on touch / pointer devices |
| keepMounted | boolean | false | Keep portal in DOM when closed (hidden via aria-hidden + inert) |
| closeOnOverlayClick | boolean | true | Close when clicking the overlay backdrop |
| closeOnEsc | boolean | true | Close on Escape key |
| lockBodyScroll | boolean | true | Lock body scroll while drawer is open |
| title | ReactNode | β | Drawer heading β omit to hide the title (header still renders if showCloseButton) |
| description | ReactNode | β | Accessible description linked via aria-describedby |
| footer | ReactNode | β | Footer content β omit to hide footer |
| children | ReactNode | β | Scrollable body content |
| initialFocusRef | RefObject<HTMLElement> | β | Element to focus when drawer opens |
| finalFocusRef | RefObject<HTMLElement> | β | Element to focus when drawer closes |
| className | string | β | Extra class on the panel element |
| style | CSSProperties | β | Inline style on the panel element |
| ref | forwarded | β | Forwarded to the panel div |
Data attributes
| Attribute | Values | Description |
|---|---|---|
| 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" | absent | Present on panel while swipe drag is active |
useDrawer options & return
| Key | Type | Description |
|---|---|---|
| open | boolean | Controlled open state |
| defaultOpen | boolean | Uncontrolled default (false) |
| onOpenChange | (open: boolean) => void | Open change callback |
| closeOnEsc | boolean | Close on Escape (true) |
| lockBodyScroll | boolean | Lock body scroll (true) |
| variant | "overlay" | "push" | Overlay or push mode ("overlay") |
| Returns: | ||
| isOpen | boolean | Current open state |
| open | () => void | Open the drawer |
| close | () => void | Close the drawer |
| toggle | () => void | Toggle open/closed |
| dragOffset | number | Current swipe drag offset in px (0 when not dragging) |
| isDragging | boolean | Whether a swipe drag is in progress |
| drawerProps | DrawerProps | Spread onto panel element (role, aria-*, tabIndex, id) |
| overlayProps | OverlayProps | Spread onto overlay element (data-rdrw-overlay, aria-hidden) |
| triggerProps | TriggerProps | Spread onto trigger button (aria-haspopup, aria-expanded, aria-controls) |