Vaul-quality drawer component using native <dialog> and pure CSS animations
npm install css-drawerA near drop-in replacement for Vaul using native and pure CSS animations.
Zero JavaScript animations. The only JS: dialog.showModal() and dialog.close().
| Feature | Vaul | CSS Drawer |
|---------|------|------------|
| Bundle size | ~12KB | ~2KB JS + 10.8KB CSS (gzip: ~3KB total) |
| Animation engine | JavaScript | Pure CSS |
| Nesting | Manual setup | Automatic (CSS :has()) |
| Accessibility | Built-in | Automatic (native + inert) |
| API | Controlled state | Native refs or controlled state |
``bash`
npm install css-drawer
---
`tsx
import { useRef } from 'react'
import { Drawer } from 'css-drawer/react'
function App() {
const ref = useRef
return (
<>
>
)
}
`
`ts
import { open, close } from 'css-drawer'
// Styles are auto-injected
document.querySelector('#open-btn').onclick = () => open('my-drawer')
`
`html
`
Angular's build system doesn't process CSS imports from JS modules. Import styles in your global styles.css:
`css`
/ src/styles.css /
@import 'css-drawer/styles';
Then use the native dialog API in your component:
`typescript
import { Component } from '@angular/core';
import { getTop, closeAll } from 'css-drawer';
@Component({
selector: 'app-example',
template:
})
export class ExampleComponent {
openDrawer(dialog: HTMLDialogElement) {
dialog.showModal();
}
closeDrawer(dialog: HTMLDialogElement) {
dialog.close();
}
isTopDrawer(dialog: HTMLDialogElement): boolean {
return getTop() === dialog;
}
closeAllDrawers() {
closeAll();
}
}
`
> Note: Angular's change detection runs after template-bound events like (click), so isTopDrawer() re-evaluates automatically. For zoneless Angular or programmatic updates outside template events, use signals.
---
`tsx`
import { Drawer } from 'css-drawer/react'
// Styles are auto-injected
Provides context for direction. Wrap your drawer content.
`tsx`
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| direction | 'bottom' \| 'top' \| 'left' \| 'right' \| 'modal' | 'bottom' | Direction the drawer opens from |children
| | ReactNode | - | Drawer content |
The dialog element. Supports both uncontrolled (refs) and controlled (state) modes.
> Note: open/onOpenChange props are on Content, not Root. This is intentional - Content wraps the native
#### Uncontrolled Mode (Refs)
`tsx
const ref = useRef
// Open
ref.current?.showModal()
// Close
ref.current?.close()
`
#### Controlled Mode (State)
`tsx
const [isOpen, setIsOpen] = useState(false)
...
// Open programmatically
`
The onOpenChange callback fires when:closeOnOutsideClick
- User presses Escape
- User clicks the backdrop (if is true)setIsOpen(false)
- You call
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| ref | Ref | - | Ref to control the dialog (uncontrolled mode) |open
| | boolean | - | Controlled open state |onOpenChange
| | (open: boolean) => void | - | Called when open state changes |closeOnOutsideClick
| | boolean | true | Close when clicking outside the drawer |className
| | string | - | Additional CSS classes |...props
| | DialogHTMLAttributes | - | All native dialog props |
Visual drag handle indicator.
`tsx`
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| className | string | - | Additional CSS classes |
Semantic heading for accessibility.
`tsx`
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| ...props | HTMLAttributes | - | All native h2 props |
Semantic description for accessibility.
`tsx`
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| ...props | HTMLAttributes | - | All native p props |
Hook to check if a drawer is the topmost open drawer. Useful for conditionally rendering content (like notifications) only in the top drawer.
`tsx
import { useRef } from 'react'
import { Drawer, useIsTopDrawer } from 'css-drawer/react'
function MyDrawer() {
const ref = useRef
const isTop = useIsTopDrawer(ref)
return (
{isTop && You have new messages}
{/ drawer content /}
)
}
`
| Param | Type | Description |
|-------|------|-------------|
| ref | RefObject | Ref to the drawer dialog element |
Returns: boolean - true if this drawer is currently the topmost open drawer
The hook automatically updates when any drawer opens or closes.
Utility function to get the topmost open drawer element. Useful for imperative access.
`tsx
import { getTopDrawer } from 'css-drawer/react'
const topDrawer = getTopDrawer()
topDrawer?.close()
`
Returns: HTMLDialogElement | null
---
`ts`
import { open, close, closeAll, getTop, subscribe } from 'css-drawer'
// Styles are auto-injected
Opens a drawer by ID or element reference.
`ts`
open('my-drawer')
open(document.getElementById('my-drawer'))
| Param | Type | Description |
|-------|------|-------------|
| drawer | string \| HTMLDialogElement | Drawer ID or element |
Closes a drawer by ID or element reference.
`ts`
close('my-drawer')
| Param | Type | Description |
|-------|------|-------------|
| drawer | string \| HTMLDialogElement | Drawer ID or element |
Closes all open drawers in reverse order (top to bottom).
`ts`
closeAll()
Returns whether a drawer is open.
`ts`
if (isOpen('my-drawer')) {
// ...
}
| Param | Type | Description |
|-------|------|-------------|
| drawer | string \| HTMLDialogElement | Drawer ID or element |
Returns: boolean
Returns all currently open drawers.
`ts`
const openDrawers = getOpen()
Returns: HTMLDialogElement[]
Returns the topmost open drawer.
`ts`
const topDrawer = getTop()
topDrawer?.close()
Returns: HTMLDialogElement | null
Creates a drawer element programmatically.
`ts
const drawer = create({
id: 'my-drawer',
content: 'Hello
',
handle: true,
className: 'custom-class'
})
mount(drawer)
open(drawer)
`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| id | string | - | Drawer ID |content
| | string | '' | HTML content |direction
| | DrawerDirection | 'bottom' | Direction the drawer opens from |handle
| | boolean | true | Include drag handle |className
| | string | '' | Additional CSS classes |closeOnOutsideClick
| | boolean | true | Close when clicking outside |
Returns: HTMLDialogElement
Appends a drawer to the document body.
`ts`
const drawer = create({ id: 'my-drawer' })
mount(drawer)
| Param | Type | Description |
|-------|------|-------------|
| drawer | HTMLDialogElement | Drawer element |
Returns: HTMLDialogElement
Removes a drawer from the DOM.
`ts`
unmount('my-drawer')
| Param | Type | Description |
|-------|------|-------------|
| drawer | string \| HTMLDialogElement | Drawer ID or element |
Subscribe to drawer events.
`ts
const unsubscribe = subscribe('my-drawer', {
onOpen: () => console.log('Opened'),
onClose: () => console.log('Closed'),
onCancel: () => console.log('Cancelled (Escape/backdrop)')
})
// Later
unsubscribe()
`
| Param | Type | Description |
|-------|------|-------------|
| drawer | string \| HTMLDialogElement | Drawer ID or element |handlers.onOpen
| | () => void | Called when drawer opens |handlers.onClose
| | () => void | Called when drawer closes |handlers.onCancel
| | () => void | Called on Escape or backdrop click |
Returns: () => void (cleanup function)
---
`tsx`
`html`
`tsx
const isMobile = useMediaQuery('(max-width: 768px)')
...
`
| Direction | Description |
|-----------|-------------|
| bottom | Opens from bottom (default) |top
| | Opens from top |left
| | Opens from left |right
| | Opens from right |modal
| | Centered modal with scale animation |
---
Drawers automatically stack when opened. No configuration needed.
For the best visual stacking effect (scale + dim), place drawers as siblings in the DOM:
`tsx
const drawer1 = useRef
const drawer2 = useRef
// Open drawer1
drawer1.current?.showModal()
// Open drawer2 on top
drawer2.current?.showModal()
// drawer1 automatically scales down and dims
<>
>
`
`tsx
const [settingsOpen, setSettingsOpen] = useState(false)
const [confirmOpen, setConfirmOpen] = useState(false)
<>
>
`
Works up to 5 levels. CSS :has() selectors handle the visual stacking.
You can also nest a drawer inside another drawer's content (DOM nesting). This pattern works functionally—close events, buttons, and accessibility all work correctly—but the automatic CSS scaling effect is not applied to DOM-nested dialogs.
` Parent contenttsx
// DOM-nested: works but no auto-scaling
{/ Child nested inside parent /} Child content
`
Use the sibling pattern if you want the visual stacking effect.
---
Accessibility is automatic:
- Focus trapping: Native
No setup required.
---
By default, clicking the backdrop closes the drawer. Disable this for forms or when accidental dismissal could cause data loss.
`tsx`
{/ Form content - won't close on backdrop click /}
`ts`
const drawer = create({
id: 'form-drawer',
closeOnOutsideClick: false
})
`html`
> Note: Users can still close with Escape key (native dialog behavior) or explicit close buttons.
---
Override any of these CSS custom properties to customize the drawer:
`css
:root {
/ Visual /
--drawer-bg: #fff;
--drawer-radius: 24px;
--drawer-backdrop: hsl(0 0% 0% / 0.4);
--drawer-backdrop-blur: 4px;
/ Sizing /
--drawer-max-width: 500px;
--drawer-max-height: 96dvh;
/ Handle /
--drawer-handle-bg: hsl(0 0% 80%);
--drawer-handle-bg-hover: hsl(0 0% 60%);
--drawer-handle-width: 48px;
--drawer-handle-width-hover: 56px;
--drawer-handle-height: 5px;
--drawer-handle-padding-block: 1rem 0.5rem;
--drawer-handle-padding-inline: 0;
/ Shadows /
--drawer-shadow-bottom: 0 -10px 60px hsl(0 0% 0% / 0.12), 0 -4px 20px hsl(0 0% 0% / 0.08);
--drawer-shadow-top: 0 10px 60px hsl(0 0% 0% / 0.12), 0 4px 20px hsl(0 0% 0% / 0.08);
--drawer-shadow-right: -10px 0 60px hsl(0 0% 0% / 0.12), -4px 0 20px hsl(0 0% 0% / 0.08);
--drawer-shadow-left: 10px 0 60px hsl(0 0% 0% / 0.12), 4px 0 20px hsl(0 0% 0% / 0.08);
--drawer-shadow-modal: 0 25px 50px -12px hsl(0 0% 0% / 0.25);
/ Animation /
--drawer-duration: 0.5s;
--drawer-duration-close: 0.35s;
--drawer-ease: cubic-bezier(0.32, 0.72, 0, 1);
/ Nesting effects /
--drawer-nested-scale: 0.94;
--drawer-nested-offset: 20px;
--drawer-nested-brightness: 0.92;
--drawer-nested-backdrop: hsl(0 0% 0% / 0.15);
}
`
#### Visual
| Variable | Default (Light) | Default (Dark) | Description |
|----------|-----------------|----------------|-------------|
| --drawer-bg | #fff | hsl(0 0% 12%) | Background color |--drawer-radius
| | 24px | Same | Base border radius value |--drawer-border-radius
| | Direction-based | Same | Full border-radius override (e.g., 16px 16px 0 0) |--drawer-backdrop
| | hsl(0 0% 0% / 0.4) | Same | Backdrop overlay color |--drawer-backdrop-blur
| | 4px | Same | Backdrop blur amount |
#### Sizing
| Variable | Default | Description |
|----------|---------|-------------|
| --drawer-width | direction-based | Drawer width (100% for bottom/top, 500px for left/right) |--drawer-height
| | direction-based | Drawer height (auto for bottom/top, 100dvh for left/right) |--drawer-max-width
| | direction-based | Maximum width (none for bottom/top, 90% for left/right/modal) |--drawer-max-height
| | 96dvh | Maximum height (bottom/top/modal only) |--drawer-modal-width
| | fit-content | Modal width |--drawer-modal-height
| | fit-content | Modal height |
> Note: --drawer-width, --drawer-height, and --drawer-max-width are not defined globally—each direction uses sensible fallbacks. Set these per-instance to override.
> Fullscreen modal: Set --drawer-modal-width, --drawer-modal-height, --drawer-max-width, and --drawer-max-height to 100%.
#### Handle
| Variable | Default (Light) | Default (Dark) | Description |
|----------|-----------------|----------------|-------------|
| --drawer-handle-bg | hsl(0 0% 80%) | hsl(0 0% 35%) | Handle background color |--drawer-handle-bg-hover
| | hsl(0 0% 60%) | hsl(0 0% 50%) | Handle hover color |--drawer-handle-width
| | 48px | Same | Handle width |--drawer-handle-width-hover
| | 56px | Same | Handle width on hover |--drawer-handle-height
| | 5px | Same | Handle height/thickness |--drawer-handle-padding-block
| | 1rem 0.5rem | Same | Handle vertical padding |--drawer-handle-padding-inline
| | 0 | Same | Handle horizontal padding |
#### Shadows
| Variable | Default (Light) | Default (Dark) |
|----------|-----------------|----------------|
| --drawer-shadow-bottom | 0 -10px 60px hsl(0 0% 0% / 0.12), ... | Darker |--drawer-shadow-top
| | 0 10px 60px hsl(0 0% 0% / 0.12), ... | Darker |--drawer-shadow-left
| | 10px 0 60px hsl(0 0% 0% / 0.12), ... | Darker |--drawer-shadow-right
| | -10px 0 60px hsl(0 0% 0% / 0.12), ... | Darker |--drawer-shadow-modal
| | 0 25px 50px -12px hsl(0 0% 0% / 0.25) | Darker |
#### Animation
| Variable | Default | Description |
|----------|---------|-------------|
| --drawer-duration | 0.5s | Open animation duration |--drawer-duration-close
| | 0.35s | Close animation duration |--drawer-ease
| | cubic-bezier(0.32, 0.72, 0, 1) | Animation easing curve |
#### Nesting Effects
| Variable | Default | Description |
|----------|---------|-------------|
| --drawer-nested-scale | 0.94 | Scale factor for stacked drawers |--drawer-nested-offset
| | 20px | Vertical offset per nesting level |--drawer-nested-brightness
| | 0.92 | Brightness multiplier for stacked drawers |--drawer-nested-backdrop
| | hsl(0 0% 0% / 0.15) | Backdrop color for nested drawers |
Dark mode is automatic via prefers-color-scheme. Override for manual control:
`css`
/ Force dark mode /
.dark .drawer,
[data-theme="dark"] .drawer {
--drawer-bg: hsl(0 0% 12%);
--drawer-handle-bg: hsl(0 0% 35%);
--drawer-handle-bg-hover: hsl(0 0% 50%);
--drawer-shadow-bottom: 0 -10px 60px hsl(0 0% 0% / 0.4), 0 -4px 20px hsl(0 0% 0% / 0.3);
}
CSS Drawer works with Tailwind v4. Use CSS custom properties in your theme:
`css
@import "tailwindcss";
@layer base {
:root {
--drawer-bg: var(--color-white);
--drawer-radius: var(--radius-2xl);
--drawer-handle-bg: var(--color-zinc-300);
--drawer-backdrop: oklch(0% 0 0 / 0.4);
}
.dark {
--drawer-bg: var(--color-zinc-900);
--drawer-handle-bg: var(--color-zinc-600);
}
}
`
You can also pass Tailwind classes directly to components:
`tsx`
> Note: Base drawer styles have equal specificity to Tailwind utilities. For guaranteed overrides, use CSS custom properties or increase specificity with a wrapper class.
For Tailwind v3, use the theme() function in your CSS:
`css
@layer base {
:root {
--drawer-bg: theme('colors.white');
--drawer-radius: theme('borderRadius.2xl');
--drawer-handle-bg: theme('colors.zinc.300');
}
.dark {
--drawer-bg: theme('colors.zinc.900');
--drawer-handle-bg: theme('colors.zinc.600');
}
}
`
Override variables on individual drawers:
`tsx`
style={{
'--drawer-bg': '#f0f0f0',
'--drawer-radius': '16px',
'--drawer-max-width': '400px'
} as React.CSSProperties}
>
...
`html`
style="--drawer-bg: #f0f0f0; --drawer-radius: 16px;"
>
...
By default, border radius is direction-aware (e.g., bottom drawer rounds top corners). Override with --drawer-border-radius for full control:
`tsx
{/ Round all corners /}
style={{ '--drawer-border-radius': '16px' } as React.CSSProperties}
>
{/ Asymmetric corners /}
style={{ '--drawer-border-radius': '24px 24px 8px 8px' } as React.CSSProperties}
>
`
`html`
...
---
| Class | Description |
|-------|-------------|
| .drawer | Required on the dialog element |.drawer-handle
| | Visual drag handle |.drawer-content
| | Scrollable content area (structural only - add your own padding) |
> Note: The .drawer-content class is intentionally unopinionated - it only provides scroll behavior (overflow-y: auto, overscroll-behavior: contain). Add your own padding to match your design system.
---
| Browser | Version |
|---------|---------|
| Chrome | 117+ |
| Safari | 17.5+ |
| Firefox | 129+ |
Uses @starting-style, :has(), allow-discrete, and dvh units.
---
Full TypeScript support included.
`tsx
// React
import {
Drawer,
useIsTopDrawer,
getTopDrawer,
type DrawerRootProps,
type DrawerContentProps,
type DrawerDirection
} from 'css-drawer/react'
// Vanilla JS
import {
open,
close,
getTop,
closeAll,
subscribe,
create,
type DrawerElement,
type DrawerRef,
type DrawerDirection
} from 'css-drawer'
``
---
MIT