# content-drawer

Slide-in drawers and sheets for nav menus, filters, confirmations, and side panels — any edge, with peek stages.


```html
<section>
  <button type="button" command="--toggle" commandfor="demo-drawer-simple">
    Toggle sheet
  </button>
  <content-drawer id="demo-drawer-simple" class="absolute">
    <header>
      <button type="button" rel="prev" command="--close" commandfor="demo-drawer-simple" aria-label="Close"></button>
      <h2>Sheet open</h2>
    </header>
  </content-drawer>
  <event-handler class="tag-backdrop" role="presentation" target-ref="content-drawer:has(+ :scope)" command-name="--close"></event-handler>
</section>
```


## Features

- **Any edge** Bottom (default), top, left, or right via `from-side`
- **Peek stages** Full, half, or peek via `open-stage`
- **Command-driven** `--open` / `--close` / `--toggle` from any `<button command commandfor>`
- **Dismissal** Outside click + Escape via `<dismiss-watcher>`
- **Backdrop** Valence.css dimmer — sibling `[role="presentation"]` / `.tag-backdrop`
- **Auto-dismiss** `disappear-after` for toast-style confirmations
- **Singleton groups** One open drawer per `singleton-name`
- **Layout modes** Viewport sheet (default, `position: fixed`), `.absolute` (inside its parent), `.relative`, or `.sticky`
- **Scrubbable** Wrap in [`gesture-handler`](/packages/gesture-handler): the sheet follows the finger via `is-scrubbing` + `--content-drawer-open-progress`

## Installation


`@excom/content-drawer` v0.2.3

```bash
pnpm add @excom/content-drawer
```

```bash
npm install @excom/content-drawer
```

```bash
yarn add @excom/content-drawer
```

### Import

```ts
import "@excom/content-drawer";
```



## Usage

Put content inside `<content-drawer>` and invoke `--open`, `--close`, or `--toggle` on it — a native `<button command commandfor>`, or `<event-handler command-name target-ref>` when the invoker is not a button. The parent of an `.absolute` drawer becomes `position: relative; overflow: clip`, so give that parent a real size along the drawer's axis.

```html
<button type="button" command="--toggle" commandfor="sheet">Toggle sheet</button>
<content-drawer id="sheet" class="absolute">
  <h2>Saved!</h2>
</content-drawer>
<event-handler class="tag-backdrop" role="presentation" target-ref="content-drawer:has(+ :scope)" command-name="--close"></event-handler>
```

The backdrop never reads the drawer's own variables: set `--content-drawer-transition-duration`, `--content-drawer-transition-ease` and `--content-drawer-overlay-z-index` on the parent, the backdrop or `:root`. After its slide-out a closed drawer is `visibility: hidden`, so a custom `transition` on it must keep `visibility 0s <duration>`.

### API Reference


#### Attributes

| Name | Surface | Type | Default | Values | Description |
| --- | --- | --- | --- | --- | --- |
| `from-side` | option | `string` | `"bottom"` | `"bottom"` \| `"top"` \| `"left"` \| `"right"` | Edge the drawer slides from. |
| `disappear-after` | option | `number` |  |  | Auto-close after this many seconds once opened — toast-style / transient confirmations. |
| `singleton-name` | option | `string` |  |  | Shared group name. Opening one drawer closes others with the same name (singleton coordination). |
| `open-stage` | hybrid | `number` |  | `"0"` \| `"1"` \| `"2"` | How far the drawer opens: `0` full, `1` half, `2` peek. Unset = full. Set statically, or per open through `data-open-stage` on the `--open` / `--toggle` invoker. |
| `is-open` | hybrid | `boolean` |  |  | Open state. Toggle directly, or through the `--open` / `--close` / `--toggle` commands. For Escape / outside-click dismissal pair with `<dismiss-watcher command-name="--close">`. |

#### Fires

| Name | Type | Description |
| --- | --- | --- |
| `content-drawer-opened` | `ContentDrawerOpenedEvent` (`CustomEvent & { type: "content-drawer-opened"; detail: HTMLElement; bubbles: true; cancelable: true; composed: true }`) | After open (`is-open` set), `detail` is the drawer. When `singleton-name` is set it is also broadcast so drawers sharing that name close. |
| `content-drawer-closed` | `ContentDrawerClosedEvent` (`CustomEvent & { type: "content-drawer-closed"; detail: HTMLElement; bubbles: true; cancelable: true; composed: true }`) | After close (`is-open` unset), `detail` is the drawer. |

#### Commands

| Command | Action |
| --- | --- |
| `--open` | Opens the drawer (`is-open` set). `data-*` attributes on the invoker that name a declared prop (`data-open-stage`, `data-from-side`, …) are applied first; unknown, private and `isOpen` keys are ignored. |
| `--close` | Closes the drawer (`is-open` unset). |
| `--toggle` | Toggles open / closed. Reads the invoker's `data-*` like `--open`. |

#### CSS Custom Properties

| Name | Syntax | Default | Description |
| --- | --- | --- | --- |
| `--content-drawer-transition-duration` | `<time>` | `0.25s` | Slide and backdrop fade duration. Set on the parent, the backdrop or `:root`: the backdrop never reads the drawer's own value. |
| `--content-drawer-transition-ease` | `<easing-function>` | `ease-out` | Slide and backdrop fade easing. Set on the parent, the backdrop or `:root`: the backdrop never reads the drawer's own value. |
| `--content-drawer-closed` | `<percentage>` | `101%` | Off-screen translate amount (closed). |
| `--content-drawer-open-full` | `<percentage>` | `0%` | Full-open translate (`open-stage` unset or `0`). |
| `--content-drawer-open-half` | `<percentage>` | `50%` | Half-open translate (`open-stage="1"`). |
| `--content-drawer-open-peek` | `<percentage>` | `75%` | Peek translate (`open-stage="2"`). |
| `--content-drawer-relative-max-width` | `<length>` | `200px` | Max width when open in `.relative` mode, used unless `--content-drawer-max-width` is set. |
| `--content-drawer-z-index` | `<integer>` | `3` | Stack order for the drawer panel. |
| `--content-drawer-overlay-z-index` | `<integer>` | `2` | Stack order for the sibling backdrop (`:--dialog-backdrop-aliases`). Set on the parent, the backdrop or `:root`, not on the drawer. |
| `--content-drawer-open-progress` | `<number>` | `var(--gesture-progress, 0)` | How far open the drawer is while `is-scrubbing`: `0` closed, `1` full. Declared on the drawer (not `:root`) so it follows a wrapping `<gesture-handler>`'s inherited `--gesture-progress` unless set explicitly — a swipe-to-open / drag-to-close bottom sheet. |
| `--content-drawer-max-width` | `<length>` |  | Overrides `--content-drawer-relative-max-width` for this drawer only. |

#### CSS Classes

| Name | Description |
| --- | --- |
| `.close` | Close control — empty `.close` / `[rel="prev"]` button or link (theme close icon). |
| `.sticky` | `position: sticky` instead of the default `fixed` placement. |
| `.absolute` | `position: absolute` instead of the default `fixed` placement — a sheet inside its parent, which becomes `position: relative; overflow: clip`. |
| `.relative` | In-flow mode: the drawer participates in layout and animates `max-width` instead of overlaying via `position`, pushing sibling content aside as it opens. Currently only affects `from-side="left"`. |

#### CSS Aliases

| Alias | Kind | Matches | Description |
| --- | --- | --- | --- |
| `:--content-drawer` | element | `content-drawer`, `.tag-content-drawer` |  |
| `:--content-drawer--is-open` | state | `[is-open]`, `[aria-expanded="true"]` |  |
| `:--content-drawer--is-scrubbing` | state | `[is-scrubbing][is-scrubbing]`, `[data-scrubbing][data-scrubbing]` |  |
| `:--content-drawer--open-stage-1` | state | `[open-stage="1"]`, `[data-open="1"]` |  |
| `:--content-drawer--open-stage-2` | state | `[open-stage="2"]`, `[data-open="2"]` |  |
| `:--content-drawer--from-side` | state | `[from-side]`, `[data-placement]` |  |
| `:--content-drawer--from-side-bottom` | state | `[from-side="bottom"]`, `[data-placement="bottom"]` |  |
| `:--content-drawer--from-side-top` | state | `[from-side="top"]`, `[data-placement="top"]` |  |
| `:--content-drawer--from-side-left` | state | `[from-side="left"]`, `[data-placement="left"]` |  |
| `:--content-drawer--from-side-right` | state | `[from-side="right"]`, `[data-placement="right"]` |  |



### Examples

#### Peek stages

`--open` with `data-open-stage` on the button sets the stage — `0` full, `1` half, `2` peek. The exact heights of each stage are configurable through CSS variables.


```html
<section>
  <button type="button" command="--open" commandfor="demo-drawer-stages" data-open-stage="0">
    Full (100%)
  </button>
  <button type="button" command="--open" commandfor="demo-drawer-stages" data-open-stage="1">
    Half (50%)
  </button>
  <button type="button" command="--open" commandfor="demo-drawer-stages" data-open-stage="2">
    Peek (25%)
  </button>
  <content-drawer id="demo-drawer-stages" class="absolute">
    <header>
      <button type="button" rel="prev" command="--close" commandfor="demo-drawer-stages" aria-label="Close"></button>
      <h2>Open stages</h2>
    </header>
    <p>Full / half / peek via <code>data-open-stage</code> on the button.</p>
  </content-drawer>
</section>
```


#### Dismissal

`<dismiss-watcher command-name="--close">` as the drawer's first child closes it on outside click or Escape / back gesture; the sheet gates its `is-active` on `content-drawer[is-open]` (with the inverse rule). Open with `--open`, not `--toggle` — an outside `mouseup` on the button closes first, the `click` then re-opens. Immediate next sibling `[role="presentation"]` / `.tag-backdrop` is the Valence.css modal dimmer; `<event-handler command-name="--close">` on that node closes on click.


```html
<section>
  <quark-sheet>
    :scope {
      content-drawer[is-open] dismiss-watcher { is-active: ""; }
      content-drawer:not([is-open]) dismiss-watcher { is-active: none; }
    }
  </quark-sheet>
  <button type="button" command="--open" commandfor="demo-drawer-dismiss">
    Open drawer
  </button>
  <content-drawer id="demo-drawer-dismiss" class="absolute">
    <dismiss-watcher watch-escape watch-outside-click command-name="--close"></dismiss-watcher>
    <nav>Menu</nav>
  </content-drawer>
  <event-handler class="tag-backdrop" role="presentation" target-ref="content-drawer:has(+ :scope)" command-name="--close"></event-handler>
</section>
```


#### Side drawer

`from-side` slides from left / right / top instead of the default bottom.


```html
<section>
  <button type="button" command="--toggle" commandfor="demo-drawer-side">
    Toggle drawer
  </button>
  <content-drawer id="demo-drawer-side" class="absolute" from-side="left">
    <nav>Menu</nav>
  </content-drawer>
  <event-handler class="tag-backdrop" role="presentation" target-ref="content-drawer:has(+ :scope)" command-name="--close"></event-handler>
</section>
```


#### Auto-dismiss

`disappear-after` closes the drawer after N seconds — useful for success toasts.


```html
<section>
  <button type="button" command="--open" commandfor="demo-drawer-disappear">
    Show success
  </button>
  <content-drawer id="demo-drawer-disappear" class="absolute" disappear-after="2">
    <h2>Saved!</h2>
  </content-drawer>
</section>
```


#### Singleton group

Drawers sharing `singleton-name` — opening one closes the other.


```html
<section>
  <button type="button" command="--open" commandfor="drawer-a">Open A</button>
  <button type="button" command="--open" commandfor="drawer-b">Open B</button>
  <content-drawer id="drawer-a" class="absolute" singleton-name="demo">
    <header>
      <button type="button" rel="prev" command="--close" commandfor="drawer-a" aria-label="Close"></button>
      <h2>Drawer A</h2>
    </header>
  </content-drawer>
  <content-drawer id="drawer-b" class="absolute" singleton-name="demo">
    <header>
      <button type="button" rel="prev" command="--close" commandfor="drawer-b" aria-label="Close"></button>
      <h2>Drawer B</h2>
    </header>
  </content-drawer>
</section>
```

## Release notes

### 0.2.0 (2026-09-30)

- Update `content-drawer` so only an `.absolute` drawer styles its parent, as `position: relative; overflow: clip` (was every drawer, with `overflow: hidden`): a fixed or sticky drawer no longer clips its parent, and sticky descendants keep working
- Update a closed `content-drawer` to be `visibility: hidden` once its slide-out ends, taking it out of the tab order and the accessibility tree: a custom `transition` on a closed drawer must keep `visibility 0s <duration>`, and a closed drawer shown in the layout needs `visibility: visible`

### 0.1.1 (2026-09-23)

- Ship only dist (and declared extras) in the npm tarball; drop build logs, tests and sources
