npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

css-drawer

v0.3.0

Published

Vaul-quality drawer component using native <dialog> and pure CSS animations

Readme

CSS Drawer

A near drop-in replacement for Vaul using native <dialog> and pure CSS animations.

Zero JavaScript animations. The only JS: dialog.showModal() and dialog.close().

Why?

| 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 <dialog> + inert) | | API | Controlled state | Native refs or controlled state |

Installation

npm install css-drawer

Quick Start

React (Recommended)

import { useRef } from 'react'
import { Drawer } from 'css-drawer/react'

function App() {
  const ref = useRef<HTMLDialogElement>(null)

  return (
    <>
      <button onClick={() => ref.current?.showModal()}>
        Open
      </button>

      <Drawer.Root>
        <Drawer.Content ref={ref}>
          <Drawer.Handle />
          <div className="drawer-content">
            <Drawer.Title>Title</Drawer.Title>
            <Drawer.Description>Description</Drawer.Description>
            <button onClick={() => ref.current?.close()}>Close</button>
          </div>
        </Drawer.Content>
      </Drawer.Root>
    </>
  )
}

Vanilla JS

import { open, close } from 'css-drawer'
// Styles are auto-injected

document.querySelector('#open-btn').onclick = () => open('my-drawer')
<button id="open-btn">Open</button>

<dialog class="drawer" id="my-drawer">
  <div class="drawer-handle"></div>
  <div class="drawer-content">
    <h2>Title</h2>
    <p>Description</p>
    <button onclick="this.closest('dialog').close()">Close</button>
  </div>
</dialog>

Angular

Angular's build system doesn't process CSS imports from JS modules. Import styles in your global styles.css:

/* src/styles.css */
@import 'css-drawer/styles';

Then use the native dialog API in your component:

import { Component } from '@angular/core';
import { getTop, closeAll } from 'css-drawer';

@Component({
  selector: 'app-example',
  template: `
    <button (click)="openDrawer(drawer)">Open</button>

    <dialog #drawer class="drawer" data-direction="modal">
      @if (isTopDrawer(drawer)) {
        <div class="top-badge">Top Drawer</div>
      }
      <div class="drawer-content">
        <h2>Title</h2>
        <button (click)="closeDrawer(drawer)">Close</button>
      </div>
    </dialog>
  `
})
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.


React API

Installation

import { Drawer } from 'css-drawer/react'
// Styles are auto-injected

Drawer.Root

Provides context for direction. Wrap your drawer content.

<Drawer.Root direction="right">
  <Drawer.Content ref={ref}>...</Drawer.Content>
</Drawer.Root>

| Prop | Type | Default | Description | |------|------|---------|-------------| | direction | 'bottom' \| 'top' \| 'left' \| 'right' \| 'modal' | 'bottom' | Direction the drawer opens from | | children | ReactNode | - | Drawer content |

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 <dialog> element, so open/close control lives where the element lives. Root only provides configuration (direction).

Uncontrolled Mode (Refs)

const ref = useRef<HTMLDialogElement>(null)

// Open
ref.current?.showModal()

// Close
ref.current?.close()

<Drawer.Content ref={ref}>...</Drawer.Content>

Controlled Mode (State)

const [isOpen, setIsOpen] = useState(false)

<Drawer.Content open={isOpen} onOpenChange={setIsOpen}>
  ...
</Drawer.Content>

// Open programmatically
<button onClick={() => setIsOpen(true)}>Open</button>

The onOpenChange callback fires when:

  • User presses Escape
  • User clicks the backdrop (if closeOnOutsideClick is true)
  • You call setIsOpen(false)

| Prop | Type | Default | Description | |------|------|---------|-------------| | ref | Ref<HTMLDialogElement> | - | 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 |

Drawer.Handle

Visual drag handle indicator.

<Drawer.Handle />

| Prop | Type | Default | Description | |------|------|---------|-------------| | className | string | - | Additional CSS classes |

Drawer.Title

Semantic heading for accessibility.

<Drawer.Title>Create Issue</Drawer.Title>

| Prop | Type | Default | Description | |------|------|---------|-------------| | ...props | HTMLAttributes<HTMLHeadingElement> | - | All native h2 props |

Drawer.Description

Semantic description for accessibility.

<Drawer.Description>Fill out the form below.</Drawer.Description>

| Prop | Type | Default | Description | |------|------|---------|-------------| | ...props | HTMLAttributes<HTMLParagraphElement> | - | All native p props |

useIsTopDrawer(ref)

Hook to check if a drawer is the topmost open drawer. Useful for conditionally rendering content (like notifications) only in the top drawer.

import { useRef } from 'react'
import { Drawer, useIsTopDrawer } from 'css-drawer/react'

function MyDrawer() {
  const ref = useRef<HTMLDialogElement>(null)
  const isTop = useIsTopDrawer(ref)

  return (
    <Drawer.Root>
      <Drawer.Content ref={ref}>
        {isTop && <div className="notification">You have new messages</div>}
        {/* drawer content */}
      </Drawer.Content>
    </Drawer.Root>
  )
}

| Param | Type | Description | |-------|------|-------------| | ref | RefObject<HTMLDialogElement \| null> | 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.

getTopDrawer()

Utility function to get the topmost open drawer element. Useful for imperative access.

import { getTopDrawer } from 'css-drawer/react'

const topDrawer = getTopDrawer()
topDrawer?.close()

Returns: HTMLDialogElement | null


Vanilla JS API

Installation

import { open, close, closeAll, getTop, subscribe } from 'css-drawer'
// Styles are auto-injected

open(drawer)

Opens a drawer by ID or element reference.

open('my-drawer')
open(document.getElementById('my-drawer'))

| Param | Type | Description | |-------|------|-------------| | drawer | string \| HTMLDialogElement | Drawer ID or element |

close(drawer)

Closes a drawer by ID or element reference.

close('my-drawer')

| Param | Type | Description | |-------|------|-------------| | drawer | string \| HTMLDialogElement | Drawer ID or element |

closeAll()

Closes all open drawers in reverse order (top to bottom).

closeAll()

isOpen(drawer)

Returns whether a drawer is open.

if (isOpen('my-drawer')) {
  // ...
}

| Param | Type | Description | |-------|------|-------------| | drawer | string \| HTMLDialogElement | Drawer ID or element |

Returns: boolean

getOpen()

Returns all currently open drawers.

const openDrawers = getOpen()

Returns: HTMLDialogElement[]

getTop()

Returns the topmost open drawer.

const topDrawer = getTop()
topDrawer?.close()

Returns: HTMLDialogElement | null

create(options)

Creates a drawer element programmatically.

const drawer = create({
  id: 'my-drawer',
  content: '<h2>Hello</h2>',
  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

mount(drawer)

Appends a drawer to the document body.

const drawer = create({ id: 'my-drawer' })
mount(drawer)

| Param | Type | Description | |-------|------|-------------| | drawer | HTMLDialogElement | Drawer element |

Returns: HTMLDialogElement

unmount(drawer)

Removes a drawer from the DOM.

unmount('my-drawer')

| Param | Type | Description | |-------|------|-------------| | drawer | string \| HTMLDialogElement | Drawer ID or element |

subscribe(drawer, handlers)

Subscribe to drawer events.

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)


Directions

React

<Drawer.Root direction="right">
  <Drawer.Content ref={ref}>...</Drawer.Content>
</Drawer.Root>

Vanilla

<dialog class="drawer" data-direction="right">...</dialog>

Responsive Direction

const isMobile = useMediaQuery('(max-width: 768px)')

<Drawer.Root direction={isMobile ? 'bottom' : 'right'}>
  ...
</Drawer.Root>

| 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 |


Auto-Nesting

Drawers automatically stack when opened. No configuration needed.

Recommended: Sibling Pattern

For the best visual stacking effect (scale + dim), place drawers as siblings in the DOM:

const drawer1 = useRef<HTMLDialogElement>(null)
const drawer2 = useRef<HTMLDialogElement>(null)

// Open drawer1
drawer1.current?.showModal()

// Open drawer2 on top
drawer2.current?.showModal()
// drawer1 automatically scales down and dims

<>
  <Drawer.Root>
    <Drawer.Content ref={drawer1}>First drawer</Drawer.Content>
  </Drawer.Root>

  <Drawer.Root>
    <Drawer.Content ref={drawer2}>Second drawer (sibling)</Drawer.Content>
  </Drawer.Root>
</>

With Controlled State

const [settingsOpen, setSettingsOpen] = useState(false)
const [confirmOpen, setConfirmOpen] = useState(false)

<>
  <button onClick={() => setSettingsOpen(true)}>Settings</button>

  <Drawer.Root>
    <Drawer.Content open={settingsOpen} onOpenChange={setSettingsOpen}>
      <button onClick={() => setConfirmOpen(true)}>Delete Account</button>
    </Drawer.Content>
  </Drawer.Root>

  <Drawer.Root>
    <Drawer.Content open={confirmOpen} onOpenChange={setConfirmOpen}>
      <button onClick={() => {
        setConfirmOpen(false)
        setSettingsOpen(false)
      }}>
        Confirm
      </button>
    </Drawer.Content>
  </Drawer.Root>
</>

Works up to 5 levels. CSS :has() selectors handle the visual stacking.

DOM-Nested Dialogs

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.

// DOM-nested: works but no auto-scaling
<Drawer.Root>
  <Drawer.Content open={parentOpen} onOpenChange={setParentOpen}>
    <p>Parent content</p>

    {/* Child nested inside parent */}
    <Drawer.Root>
      <Drawer.Content open={childOpen} onOpenChange={setChildOpen}>
        <p>Child content</p>
      </Drawer.Content>
    </Drawer.Root>
  </Drawer.Content>
</Drawer.Root>

Use the sibling pattern if you want the visual stacking effect.


Accessibility

Accessibility is automatic:

  • Focus trapping: Native <dialog> traps focus
  • Escape to close: Native <dialog> behavior
  • Stacked drawers: Underlying drawers get inert attribute automatically
  • Screen readers: Only the top drawer is accessible

No setup required.


Preventing Outside Click Close

By default, clicking the backdrop closes the drawer. Disable this for forms or when accidental dismissal could cause data loss.

React

<Drawer.Content ref={ref} closeOnOutsideClick={false}>
  {/* Form content - won't close on backdrop click */}
</Drawer.Content>

Vanilla JS

const drawer = create({
  id: 'form-drawer',
  closeOnOutsideClick: false
})

Vanilla HTML

<dialog class="drawer" data-close-on-outside-click="false">
  <!-- Won't close on backdrop click -->
</dialog>

Note: Users can still close with Escape key (native dialog behavior) or explicit close buttons.


Theming

CSS Custom Properties

Override any of these CSS custom properties to customize the drawer:

: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);
}

All Variables Reference

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

Dark mode is automatic via prefers-color-scheme. Override for manual control:

/* 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);
}

Tailwind CSS v4

CSS Drawer works with Tailwind v4. Use CSS custom properties in your theme:

@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:

<Drawer.Content ref={ref} className="bg-white dark:bg-zinc-900">
  <Drawer.Handle className="bg-zinc-300 dark:bg-zinc-600" />
  <div className="drawer-content">
    <Drawer.Title className="text-xl font-semibold">Title</Drawer.Title>
  </div>
</Drawer.Content>

Note: Base drawer styles have equal specificity to Tailwind utilities. For guaranteed overrides, use CSS custom properties or increase specificity with a wrapper class.

Tailwind CSS v3

For Tailwind v3, use the theme() function in your 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');
  }
}

Per-Drawer Customization

Override variables on individual drawers:

<Drawer.Content
  ref={ref}
  style={{
    '--drawer-bg': '#f0f0f0',
    '--drawer-radius': '16px',
    '--drawer-max-width': '400px'
  } as React.CSSProperties}
>
  ...
</Drawer.Content>
<!-- Vanilla HTML -->
<dialog
  class="drawer"
  style="--drawer-bg: #f0f0f0; --drawer-radius: 16px;"
>
  ...
</dialog>

Custom Border Radius

By default, border radius is direction-aware (e.g., bottom drawer rounds top corners). Override with --drawer-border-radius for full control:

{/* Round all corners */}
<Drawer.Content
  ref={ref}
  style={{ '--drawer-border-radius': '16px' } as React.CSSProperties}
>

{/* Asymmetric corners */}
<Drawer.Content
  ref={ref}
  style={{ '--drawer-border-radius': '24px 24px 8px 8px' } as React.CSSProperties}
>
<!-- No rounded corners -->
<dialog class="drawer" style="--drawer-border-radius: 0;">
  ...
</dialog>

CSS Classes

| 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 Support

| Browser | Version | |---------|---------| | Chrome | 117+ | | Safari | 17.5+ | | Firefox | 129+ |

Uses @starting-style, :has(), allow-discrete, and dvh units.


TypeScript

Full TypeScript support included.

// 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'

License

MIT