diff --git a/.changeset/drawer.md b/.changeset/drawer.md
new file mode 100644
index 0000000..a8ace02
--- /dev/null
+++ b/.changeset/drawer.md
@@ -0,0 +1,43 @@
+---
+'@dunky.dev/drawer': minor
+'@dunky.dev/react-drawer': minor
+---
+
+Add the Drawer primitive — a modal panel that slides in from a screen edge,
+semantically a dialog with a placement, shipped as an agnostic core
+(`@dunky.dev/drawer`) plus a React binding (`@dunky.dev/react-drawer`).
+
+The dialog contract carries over — focus trap, scroll lock, Escape and
+outside-press dismissal (each gateable and vetoable), topmost-layer-first
+stacking through the shared layer stack, and the controlled `open` contract:
+a controlled drawer reports every open/close intent through `onOpenChange`
+but only moves when the prop does, so ignoring a report is a working veto.
+`placement` picks the edge the panel is anchored to
+(`left | right | top | bottom`, defaulting to `right`).
+Placement is pure configuration: it never gates behavior, it rides along as a
+`data-placement` styling hook on the visual layers (Backdrop, Viewport,
+Content) next to `data-state`, so slide animations key off attributes. A
+drawer is always modal in v0 — there is no `modal` or `role` switch; snap
+points and swipe gestures are out of scope by decision.
+
+```tsx
+import { Drawer } from '@dunky.dev/react-drawer'
+
+function App() {
+ return (
+
+ Settings
+
+
+
+
+ Board settings
+ Configure this board.
+ Done
+
+
+
+
+ )
+}
+```
diff --git a/packages/core/drawer/README.md b/packages/core/drawer/README.md
new file mode 100644
index 0000000..ff3d1e3
--- /dev/null
+++ b/packages/core/drawer/README.md
@@ -0,0 +1,27 @@
+# @dunky.dev/drawer
+
+The framework-agnostic drawer interaction, modeled as a state machine on
+`@dunky.dev/state-machine`. Pure logic — no substrate, no framework. Consumers
+pair it with a substrate driver rather than driving the machine directly.
+
+The behavior contract — scenarios, guarantees, driver obligations — lives in
+[SPEC.md](./SPEC.md).
+
+## Install
+
+```sh
+npm install @dunky.dev/drawer
+```
+
+## Usage
+
+```ts
+import { machine, connector } from '@dunky.dev/state-machine'
+import { drawerMachine, drawerConnect } from '@dunky.dev/drawer'
+
+// `id` is substrate-minted (SSR-safe); the connect derives the per-part ids.
+const service = machine(drawerMachine({ ...options, id: 'my-drawer' }))
+connector(service, drawerConnect, options) // wires the consumer callbacks
+service.start()
+service.send({ type: 'toggle' })
+```
diff --git a/packages/core/drawer/SPEC.md b/packages/core/drawer/SPEC.md
new file mode 100644
index 0000000..4dd0c05
--- /dev/null
+++ b/packages/core/drawer/SPEC.md
@@ -0,0 +1,170 @@
+# SPEC / Drawer
+
+## Reference
+
+- **W3C pattern**: [APG Dialog (Modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/),
+ over the normative
+ [WAI-ARIA 1.2 `dialog` / `aria-modal`](https://www.w3.org/TR/wai-aria-1.2/#dialog)
+ definitions — a drawer is semantically a modal dialog with a placement.
+- **State machine**: built on `@dunky.dev/state-machine`.
+- **Prior art**: API shape modeled on the Ark and Chakra drawers; the shared
+ dialog conventions follow the Radix, Base UI, and Ark dialogs.
+
+## Overview
+
+A drawer is a modal panel that slides in from an edge of the screen and takes
+over interaction until the user completes or dismisses it. Semantically it is
+a dialog; what makes it a drawer is the placement — the panel is anchored to
+one edge (left, right, top, or bottom) instead of floating over the center.
+It is the home for edge-anchored workflows: settings panels, filters, carts,
+and navigation that need the page kept visually in context behind them.
+
+## Anatomy
+
+```
+ — root; owns open/close state, renders nothing of its own
+ |_ — the control that opens the drawer; focus returns here on close
+ |_ — the layer behind the drawer panel, covering the page
+ |_ — the positioning layer that anchors the panel to its edge
+ |_ — the drawer panel itself
+ |_ — names the drawer
+ |_ — describes the drawer
+ |_ — the in-drawer dismissal affordance
+```
+
+## Behavior
+
+Using the drawer is a walkthrough of intent, not a prop list:
+
+- The **root** owns open/close state, exposed controlled and uncontrolled,
+ mirroring native patterns: an uncontrolled drawer can be seeded open, while a
+ controlled consumer drives it from outside. Every open/close intent —
+ trigger press, Escape, outside press, imperative close — is reported back;
+ a controlled drawer stops there and only moves when the `open` prop does,
+ so ignoring a reported intent is how the consumer vetoes it. Whether the
+ drawer is controlled is fixed at mount. The root also fixes the
+ **placement**: which edge the panel is anchored to, defaulting to the right
+ (the trailing-edge convention drawers share). Placement is configuration,
+ not state — it never changes while the drawer is open, and every visual part
+ carries it as a `data-placement` styling hook next to `data-state`.
+- The **trigger** toggles the drawer and carries the popup relationship to
+ assistive tech. It is also the element focus returns to on close.
+- Pressing the **backdrop** — or the **viewport** area around the panel —
+ counts as an "outside interaction"; whether that dismisses follows the
+ drawer's dismissal settings, whichever layer was pressed. Presses inside the
+ content never count as outside.
+- The **content** is the drawer panel, labelled and described by the Title and
+ Description parts when they are rendered.
+- **Title / Description** name and describe the drawer. The drawer's ARIA name
+ and description always follow what is actually rendered — an omitted part
+ never leaves a dangling reference. A drawer should always have a Title (APG
+ requires an accessible name); when it genuinely can't, an accessible label
+ goes on the content instead.
+- **Close** dismisses from inside — the visible close affordance the APG
+ strongly recommends alongside Escape.
+
+Dismissal is configurable at the root: Escape closing and outside-press closing
+can each be toggled off, and the consumer can veto a single occurrence of
+either from its handler.
+
+Drawers stack with other overlay layers — a dialog opened from within a drawer
+(or a drawer from a dialog) sits on top of it, and the stack unwinds one layer
+at a time. The full contract is [Nesting](#nesting) under Accessibility.
+
+## States
+
+| State | Behavior |
+| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
+| `closed` | Nothing is shown beyond the trigger. Open intents (trigger press, imperative open) move to `open`. |
+| `open` | Backdrop and content are shown. Close intents close unconditionally; Escape and outside-press close only if their respective settings allow it. |
+
+### Title/Description presence
+
+A Title or Description can appear or disappear at any time, open or closed —
+the ARIA relationships on Content always follow what is actually rendered.
+
+## Accessibility
+
+Per APG Dialog (Modal) — a drawer is always modal:
+
+- **Roles**: Content is `dialog` with `aria-modal`.
+- **Name**: the drawer is labelled by the rendered Title, or by an accessible
+ label on Content in the no-title case. One of the two must be present.
+- **Focus**: trapped inside the drawer while open — the full contract is
+ [Focus trap](#focus-trap) below.
+- **Keyboard**: Escape closes (unless gated/vetoed). A visible Close button is
+ part of the composition guidance, not just Escape.
+- **Scroll**: the page behind an open drawer doesn't scroll, and hiding its
+ scrollbar doesn't shift the page layout.
+- **Nesting**: only the topmost layer of a stack exists for the user — the
+ full contract is [Nesting](#nesting) below.
+
+### Focus trap
+
+Per the [APG modal-dialog keyboard interaction](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/#keyboardinteraction):
+
+- **When it applies**: while the drawer is open. In a stack only the topmost
+ layer traps — the ones beneath it are unreachable until it closes.
+- **On open**: focus moves into the drawer — that is the strict rule. The
+ consumer-designated initial-focus element wins when one is set and can take
+ focus; otherwise a drawer that collects input starts at its first form field
+ (input, select, textarea), and any other drawer focuses the panel itself
+ (Content), which is focusable in script but not in the tab order.
+- **While open**: Tab moves forward through the drawer's focusables and wraps
+ from the last back to the first; Shift+Tab moves backward and wraps from the
+ first — or from the panel itself — to the last. Focus never tabs out of the
+ drawer.
+- **No focusables**: Tab is a no-op; focus stays on the panel.
+- **On close**: focus returns to the element focused before opening (normally
+ the Trigger).
+
+### Nesting
+
+Drawers join the shared overlay layer stack (`@dunky.dev/dom-layer-stack`), so
+they stack with any overlay primitive — another drawer, a dialog — under one
+contract. Each layer stays fully independent: its own open/close state,
+dismissal settings, and open/close reporting.
+
+- **Topmost only**: only the topmost layer is interactive and exposed to
+ assistive technology; everything beneath the topmost modal layer is hidden
+ and unreachable, by pointer, keyboard, or screen reader.
+- **Escape**: dismisses only the topmost layer, subject to that layer's own
+ dismissal settings — a stack unwinds one layer per press.
+- **Outside press**: pressing around the topmost layer is an outside
+ interaction for that layer alone; the layers beneath are unaffected.
+- **Unwinding**: when the topmost layer closes, the one beneath becomes
+ topmost again — re-exposed, interactive, with focus restored to the element
+ focused before the closed layer opened.
+- **Scroll**: the page stays scroll-locked until the last modal layer in the
+ stack closes.
+
+## Constraints
+
+- Content must always resolve an accessible name — from a rendered Title or an
+ accessible label — never neither.
+- ARIA labelled-by / described-by must only reference elements that are
+ actually rendered.
+- While open, focus stays trapped within the drawer; on close it returns to
+ the element focused before opening.
+- Every open ⇄ close intent, whatever its source, is reported to the
+ consumer. A controlled drawer never transitions on its own — it follows the
+ `open` prop alone, and a prop-driven transition is not echoed back.
+- Placement is fixed configuration: it selects an edge and rides along as a
+ styling hook; it never gates behavior.
+- Out of scope for v0, by decision: snap points, swipe/drag gestures, and a
+ non-modal mode. A drawer is always modal — `aria-modal`, focus trap, scroll
+ lock, backdrop — until a future version says otherwise.
+
+## Internals
+
+- **A drawer is a dialog with placement, not a dialog option.** The overlap
+ with the dialog core is intentional and the packages stay independent —
+ primitives never cross-import; the shared mechanics (layer stack, focus
+ trap, scroll lock) live in the shared DOM utils instead.
+- **Placement lives in context, not in the state graph.** It changes nothing
+ about transitions or gating; the connect stamps it on the visual parts
+ (backdrop, viewport, content) as `data-placement` so styling and slide
+ animations key off it.
+- **Always modal.** Dropping the `modal` and `role` switches keeps the v0
+ surface honest: there is no non-modal drawer to configure, and the panel is
+ always a plain `dialog` (urgent interruptions are the alert dialog's job).
diff --git a/packages/core/drawer/package.json b/packages/core/drawer/package.json
new file mode 100644
index 0000000..c0bcb63
--- /dev/null
+++ b/packages/core/drawer/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@dunky.dev/drawer",
+ "version": "0.0.0",
+ "description": "Framework-agnostic drawer interaction, modeled as a state machine.",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/dunky-dev/ui.git",
+ "directory": "packages/core/drawer"
+ },
+ "files": [
+ "dist"
+ ],
+ "type": "module",
+ "sideEffects": false,
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "publishConfig": {
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "access": "public"
+ },
+ "scripts": {
+ "build": "tsdown"
+ },
+ "dependencies": {
+ "@dunky.dev/state-machine": "^0.1.0",
+ "@dunky.dev/state-machine-bindings": "^0.1.0"
+ }
+}
diff --git a/packages/core/drawer/src/connect.ts b/packages/core/drawer/src/connect.ts
new file mode 100644
index 0000000..ebf1ea6
--- /dev/null
+++ b/packages/core/drawer/src/connect.ts
@@ -0,0 +1,126 @@
+import { makeReaction, type Connect } from '@dunky.dev/state-machine'
+import type { AttrBindings, EventBindings, PointerPayload } from '@dunky.dev/state-machine-bindings'
+import type {
+ DrawerContext,
+ DrawerIds,
+ DrawerMachineEvent,
+ DrawerOptions,
+ DrawerPlacement,
+ DrawerStateName,
+} from './types'
+
+// The bindings a part carries, drawn from the shared agnostic vocabulary; the
+// index signature keeps parts assignable to the loose shape each substrate's
+// normalize() accepts. `data-state` is the styling/animation hook;
+// `data-placement` keys edge-specific styling on the visual layers.
+export type DrawerPartBindings = EventBindings &
+ AttrBindings & {
+ 'data-state'?: DrawerStateName
+ 'data-placement'?: DrawerPlacement
+ } & Record
+
+// The cross-part ids all derive from the one base id, so the trigger's
+// aria-controls, Content's id, and the labelledby/describedby always agree.
+function drawerIds(id: string): DrawerIds {
+ return { content: `${id}-content`, title: `${id}-title`, description: `${id}-description` }
+}
+
+/** The view-facing surface a driver reads from the running drawer machine. */
+export interface DrawerApi {
+ open: boolean
+ placement: DrawerPlacement
+ ids: DrawerIds
+ setOpen: (open: boolean) => void
+ parts: {
+ trigger: DrawerPartBindings
+ backdrop: DrawerPartBindings
+ viewport: DrawerPartBindings
+ content: DrawerPartBindings
+ title: DrawerPartBindings
+ description: DrawerPartBindings
+ close: DrawerPartBindings
+ }
+}
+
+export const drawerConnect: Connect<
+ DrawerStateName,
+ DrawerContext,
+ DrawerMachineEvent,
+ DrawerOptions,
+ DrawerApi
+> = ({ state, context, props, send }) => {
+ const open = state === 'open'
+ const dataState: DrawerStateName = open ? 'open' : 'closed'
+ const placement = context.placement
+ const ids = drawerIds(context.id)
+
+ // An outside press is an intent, not a close command: the consumer may veto
+ // it; whether it dismisses is gated in the machine.
+ const onOutsidePress = (event?: PointerPayload): void => {
+ props.onInteractOutside?.(event)
+ if (event?.defaultPrevented !== true) send({ type: 'interact.outside' })
+ }
+
+ return {
+ open,
+ placement,
+ ids,
+ setOpen(next) {
+ if (open === next) return
+ send({ type: next ? 'open' : 'close' })
+ },
+ parts: {
+ trigger: {
+ hasPopup: 'dialog',
+ expanded: open,
+ // A dangling aria-controls id is an a11y defect — only while open.
+ controls: open ? ids.content : undefined,
+ 'data-state': dataState,
+ onPress: () => send({ type: 'toggle' }),
+ },
+ backdrop: {
+ 'data-state': dataState,
+ 'data-placement': placement,
+ onPress: onOutsidePress,
+ },
+ viewport: {
+ 'data-state': dataState,
+ 'data-placement': placement,
+ onPress: onOutsidePress,
+ },
+ content: {
+ role: 'dialog',
+ id: ids.content,
+ // A drawer is always modal (see SPEC.md, Design).
+ modal: true,
+ labelledBy: context.parts.title ? ids.title : undefined,
+ describedBy: context.parts.description ? ids.description : undefined,
+ // The initial focus target: focusable in script, not in the tab order.
+ focusable: false,
+ 'data-state': dataState,
+ 'data-placement': placement,
+ },
+ title: { id: ids.title },
+ description: { id: ids.description },
+ close: {
+ onPress: () => send({ type: 'close' }),
+ },
+ },
+ }
+}
+
+const reaction = makeReaction()
+
+// One reaction per consumer callback. Reactions fire in registration order within
+// a single setContext — that order is the callback-order contract. See SPEC.md.
+// onOpenChange reads the intent mailbox, not the state: a controlled machine
+// reports intents without moving, and the prop-driven `controlled.sync`
+// transition never writes the mailbox — the consumer's own change isn't echoed.
+drawerConnect.reactions = [
+ reaction(
+ m => m.context.openIntent,
+ (intent, props) => {
+ if (intent !== null) props.onOpenChange?.(intent.open)
+ },
+ ),
+]
diff --git a/packages/core/drawer/src/index.ts b/packages/core/drawer/src/index.ts
new file mode 100644
index 0000000..891cde7
--- /dev/null
+++ b/packages/core/drawer/src/index.ts
@@ -0,0 +1,13 @@
+export { drawerMachine, type DrawerMachine } from './machine'
+export { drawerConnect, type DrawerApi, type DrawerPartBindings } from './connect'
+export type {
+ DrawerCallbacks,
+ DrawerContext,
+ DrawerIds,
+ DrawerMachineEvent,
+ DrawerOptions,
+ DrawerPart,
+ DrawerPlacement,
+ DrawerStateName,
+} from './types'
+export type { KeyboardPayload, PointerPayload } from '@dunky.dev/state-machine-bindings'
diff --git a/packages/core/drawer/src/machine.ts b/packages/core/drawer/src/machine.ts
new file mode 100644
index 0000000..38a09aa
--- /dev/null
+++ b/packages/core/drawer/src/machine.ts
@@ -0,0 +1,97 @@
+import {
+ and,
+ setup,
+ type Action,
+ type Guard,
+ type Machine,
+ type TransitionConfig,
+} from '@dunky.dev/state-machine'
+import type { DrawerContext, DrawerMachineEvent, DrawerOptions, DrawerStateName } from './types'
+
+/** The running drawer machine — what a substrate holds and sends events to. */
+export type DrawerMachine = Machine
+
+type DrawerAction = Action
+type DrawerGuard = Guard
+
+const isControlled: DrawerGuard = ({ context }) => context.controlled
+const canEscape: DrawerGuard = ({ context }) => context.closeOnEscape
+const canDismissOutside: DrawerGuard = ({ context }) => context.closeOnInteractOutside
+const syncOpens: DrawerGuard = ({ event }) => event.type === 'controlled.sync' && event.open
+const syncCloses: DrawerGuard = ({ event }) => event.type === 'controlled.sync' && !event.open
+
+// Every open/close intent lands in the mailbox; the connect's reaction turns
+// it into onOpenChange. Uncontrolled, the intent rides along with the
+// transition; controlled, the intent IS the outcome — the machine stays put
+// until `controlled.sync` echoes the prop back.
+const requestOpen: DrawerAction = ({ setContext }) => setContext({ openIntent: { open: true } })
+const requestClose: DrawerAction = ({ setContext }) => setContext({ openIntent: { open: false } })
+
+const setPartPresence: DrawerAction = ({ event, context, setContext }) => {
+ if (event.type !== 'part.presence') return
+ setContext({ parts: { ...context.parts, [event.part]: event.present } })
+}
+
+export function drawerMachine(
+ options: DrawerOptions,
+): TransitionConfig {
+ // Annotated so createMachine infers Context as DrawerContext, not the narrowed literal.
+ const context: DrawerContext = {
+ // The trailing-edge convention drawers share.
+ placement: options.placement ?? 'right',
+ closeOnEscape: options.closeOnEscape ?? true,
+ closeOnInteractOutside: options.closeOnInteractOutside ?? true,
+ controlled: options.open !== undefined,
+ openIntent: null,
+ // The substrate supplies a unique id; `drawer` is only a bare fallback.
+ id: options.id ?? 'drawer',
+ parts: { title: false, description: false },
+ }
+
+ // Each intent event lists two candidates — first guard wins: controlled
+ // only writes the mailbox; uncontrolled also takes the transition.
+ return setup.as().createMachine({
+ initial: (options.open ?? options.defaultOpen) === true ? 'open' : 'closed',
+ context,
+ // Top-level: parts report their presence from any state.
+ on: {
+ 'part.presence': { actions: setPartPresence },
+ },
+ states: {
+ closed: {
+ on: {
+ open: [
+ { guard: isControlled, actions: requestOpen },
+ { target: 'open', actions: requestOpen },
+ ],
+ toggle: [
+ { guard: isControlled, actions: requestOpen },
+ { target: 'open', actions: requestOpen },
+ ],
+ 'controlled.sync': { target: 'open', guard: syncOpens },
+ },
+ },
+ open: {
+ on: {
+ close: [
+ { guard: isControlled, actions: requestClose },
+ { target: 'closed', actions: requestClose },
+ ],
+ toggle: [
+ { guard: isControlled, actions: requestClose },
+ { target: 'closed', actions: requestClose },
+ ],
+ escape: [
+ { guard: and(canEscape, isControlled), actions: requestClose },
+ { guard: canEscape, target: 'closed', actions: requestClose },
+ ],
+ 'interact.outside': [
+ { guard: and(canDismissOutside, isControlled), actions: requestClose },
+ { guard: canDismissOutside, target: 'closed', actions: requestClose },
+ ],
+ 'controlled.sync': { target: 'closed', guard: syncCloses },
+ },
+ },
+ },
+ })
+}
diff --git a/packages/core/drawer/src/types.ts b/packages/core/drawer/src/types.ts
new file mode 100644
index 0000000..869f6fb
--- /dev/null
+++ b/packages/core/drawer/src/types.ts
@@ -0,0 +1,86 @@
+// Public + machine-facing types for the framework-agnostic drawer primitive.
+// The state machine is substrate-free: all event reading lives in a
+// per-substrate driver.
+import type { KeyboardPayload, PointerPayload } from '@dunky.dev/state-machine-bindings'
+
+export type DrawerStateName = 'closed' | 'open'
+
+/** The screen edge the drawer panel is anchored to. */
+export type DrawerPlacement = 'left' | 'right' | 'top' | 'bottom'
+
+/** The parts whose presence drives the ARIA relationships on Content. */
+export type DrawerPart = 'title' | 'description'
+
+/**
+ * The cross-part ids, derived from the one `id` on context: each renders as
+ * `id` on one element and as an ARIA reference (aria-controls / labelledby /
+ * describedby) on another, and the connect wires both sides.
+ */
+export interface DrawerIds {
+ content: string
+ title: string
+ description: string
+}
+
+export interface DrawerContext {
+ placement: DrawerPlacement
+ closeOnEscape: boolean
+ closeOnInteractOutside: boolean
+ // Whether the consumer controls `open`. Fixed at build time: a controlled
+ // machine never moves on its own — intents go out through `openIntent` and
+ // only `controlled.sync` (the prop echo) transitions it.
+ controlled: boolean
+ // Emission mailbox for onOpenChange: a fresh token per intent so the
+ // reaction fires even when the intended value repeats.
+ openIntent: { open: boolean } | null
+ // The base id (substrate-minted, SSR-safe); the connect derives the per-part
+ // ids from it.
+ id: string
+ // Which optional parts are currently present, so Content only references the
+ // ones actually rendered.
+ parts: Record
+}
+
+// Dismissal intents (`escape` / `interact.outside`) are distinct from `close`
+// so the machine can gate them. `controlled.sync` is the controlled driver:
+// the substrate sends it when the `open` prop changes, and it is the only
+// event that moves a controlled machine.
+export type DrawerMachineEvent =
+ | { type: 'open' }
+ | { type: 'close' }
+ | { type: 'toggle' }
+ | { type: 'escape' }
+ | { type: 'interact.outside' }
+ | { type: 'controlled.sync'; open: boolean }
+ | { type: 'part.presence'; part: DrawerPart; present: boolean }
+
+export interface DrawerCallbacks {
+ /** Fired on every open/close transition with the new value. */
+ onOpenChange?: (open: boolean) => void
+ /** Fired before an Escape dismissal; `preventDefault()` vetoes it. */
+ onEscapeKeyDown?: (event: KeyboardPayload) => void
+ /** Fired before an outside-press dismissal; `preventDefault()` vetoes it. */
+ onInteractOutside?: (event?: PointerPayload) => void
+}
+
+/**
+ * The agnostic drawer options — the behavior a consumer configures. A
+ * substrate's props extend this with its own concerns (e.g. `children`).
+ * A drawer is always modal — there is no `modal` or `role` switch (see
+ * SPEC.md, Design).
+ */
+export interface DrawerOptions extends DrawerCallbacks {
+ /** Base id for the drawer's parts; the substrate supplies a unique, SSR-safe
+ * one. The per-part ids (content/title/description) are derived from it. */
+ id?: string
+ /** Controlled open state; every open/close intent is reported through `onOpenChange`. */
+ open?: boolean
+ /** Initial open state for the uncontrolled drawer. @default false */
+ defaultOpen?: boolean
+ /** The screen edge the panel is anchored to. @default 'right' */
+ placement?: DrawerPlacement
+ /** Whether Escape closes the drawer. @default true */
+ closeOnEscape?: boolean
+ /** Whether pressing the backdrop closes the drawer. @default true */
+ closeOnInteractOutside?: boolean
+}
diff --git a/packages/core/drawer/tests/machine.test.ts b/packages/core/drawer/tests/machine.test.ts
new file mode 100644
index 0000000..b58d69d
--- /dev/null
+++ b/packages/core/drawer/tests/machine.test.ts
@@ -0,0 +1,275 @@
+// The agnostic core of the Drawer — machine + connect, no DOM, no framework.
+import { describe, expect, it, vi } from 'vitest'
+import { connector, machine, type Connector, type Machine } from '@dunky.dev/state-machine'
+import { drawerMachine, drawerConnect } from '@dunky.dev/drawer'
+import type {
+ DrawerApi,
+ DrawerContext,
+ DrawerIds,
+ DrawerMachineEvent,
+ DrawerOptions,
+ DrawerStateName,
+ PointerPayload,
+} from '@dunky.dev/drawer'
+
+// The per-part ids the connect derives from a base id of `drw`.
+const ids: DrawerIds = {
+ content: 'drw-content',
+ title: 'drw-title',
+ description: 'drw-description',
+}
+
+interface Harness {
+ service: Machine
+ connection: Connector
+}
+
+const build = (options: DrawerOptions = {}): Harness => {
+ const service = machine(drawerMachine({ id: 'drw', ...options }))
+ const connection = connector(service, drawerConnect, options)
+ service.start()
+ return { service, connection }
+}
+
+describe('drawer machine — open/close', () => {
+ it('starts closed by default', () => {
+ const { service } = build()
+ expect(service.state).toBe('closed')
+ })
+
+ it('starts open when defaultOpen', () => {
+ const { service } = build({ defaultOpen: true })
+ expect(service.state).toBe('open')
+ })
+
+ it('starts open when controlled open=true', () => {
+ const { service } = build({ open: true })
+ expect(service.state).toBe('open')
+ })
+
+ it('toggle flips closed -> open -> closed', () => {
+ const { service } = build()
+ service.send({ type: 'toggle' })
+ expect(service.state).toBe('open')
+ service.send({ type: 'toggle' })
+ expect(service.state).toBe('closed')
+ })
+
+ it('open/close events are one-directional (open while open is a no-op)', () => {
+ const { service } = build({ defaultOpen: true })
+ service.send({ type: 'open' })
+ expect(service.state).toBe('open')
+ service.send({ type: 'close' })
+ expect(service.state).toBe('closed')
+ service.send({ type: 'close' })
+ expect(service.state).toBe('closed')
+ })
+})
+
+describe('drawer machine — placement', () => {
+ it('defaults placement to right', () => {
+ const { service } = build()
+ expect(service.context.placement).toBe('right')
+ })
+
+ it('seeds the configured placement into context', () => {
+ const { service } = build({ placement: 'bottom' })
+ expect(service.context.placement).toBe('bottom')
+ })
+})
+
+describe('drawer machine — dismissal gating', () => {
+ it('escape closes when closeOnEscape (the default)', () => {
+ const { service } = build({ defaultOpen: true })
+ service.send({ type: 'escape' })
+ expect(service.state).toBe('closed')
+ })
+
+ it('escape is ignored when closeOnEscape=false', () => {
+ const { service } = build({ defaultOpen: true, closeOnEscape: false })
+ service.send({ type: 'escape' })
+ expect(service.state).toBe('open')
+ })
+
+ it('interact.outside closes when closeOnInteractOutside (the default)', () => {
+ const { service } = build({ defaultOpen: true })
+ service.send({ type: 'interact.outside' })
+ expect(service.state).toBe('closed')
+ })
+
+ it('interact.outside is ignored when closeOnInteractOutside=false', () => {
+ const { service } = build({ defaultOpen: true, closeOnInteractOutside: false })
+ service.send({ type: 'interact.outside' })
+ expect(service.state).toBe('open')
+ })
+})
+
+describe('drawer machine — part registration', () => {
+ it('tracks title/description presence in context', () => {
+ const { service } = build({ defaultOpen: true })
+ expect(service.context.parts.title).toBe(false)
+ expect(service.context.parts.description).toBe(false)
+
+ service.send({ type: 'part.presence', part: 'title', present: true })
+ service.send({ type: 'part.presence', part: 'description', present: true })
+ expect(service.context.parts.title).toBe(true)
+ expect(service.context.parts.description).toBe(true)
+
+ service.send({ type: 'part.presence', part: 'title', present: false })
+ expect(service.context.parts.title).toBe(false)
+ expect(service.context.parts.description).toBe(true)
+ })
+
+ it('registration works in any state (top-level events)', () => {
+ const { service } = build()
+ service.send({ type: 'part.presence', part: 'title', present: true })
+ expect(service.state).toBe('closed')
+ expect(service.context.parts.title).toBe(true)
+ })
+})
+
+describe('drawer connect — logical bindings', () => {
+ it('trigger carries the popup relationship and toggles', () => {
+ const { service, connection } = build()
+ const trigger = connection.snapshot.parts.trigger
+ expect(trigger.hasPopup).toBe('dialog')
+ expect(trigger.expanded).toBe(false)
+ expect(trigger.controls).toBeUndefined() // nothing to control while closed
+
+ trigger.onPress?.()
+ expect(service.state).toBe('open')
+
+ const openTrigger = connection.snapshot.parts.trigger
+ expect(openTrigger.expanded).toBe(true)
+ expect(openTrigger.controls).toBe(ids.content)
+
+ openTrigger.onPress?.()
+ expect(service.state).toBe('closed')
+ })
+
+ it('content carries the dialog role, modality, and identity', () => {
+ const { connection } = build({ defaultOpen: true })
+ const content = connection.snapshot.parts.content
+ expect(content.role).toBe('dialog')
+ expect(content.modal).toBe(true) // a drawer is always modal
+ expect(content.id).toBe(ids.content)
+ expect(content.focusable).toBe(false) // the focus target: tabIndex -1
+ })
+
+ it('labelledBy/describedBy only reference rendered parts', () => {
+ const { service, connection } = build({ defaultOpen: true })
+ expect(connection.snapshot.parts.content.labelledBy).toBeUndefined()
+ expect(connection.snapshot.parts.content.describedBy).toBeUndefined()
+
+ service.send({ type: 'part.presence', part: 'title', present: true })
+ service.send({ type: 'part.presence', part: 'description', present: true })
+ expect(connection.snapshot.parts.content.labelledBy).toBe(ids.title)
+ expect(connection.snapshot.parts.content.describedBy).toBe(ids.description)
+
+ expect(connection.snapshot.parts.title.id).toBe(ids.title)
+ expect(connection.snapshot.parts.description.id).toBe(ids.description)
+ })
+
+ it('backdrop press dismisses through the machine', () => {
+ const { service, connection } = build({ defaultOpen: true })
+ connection.snapshot.parts.backdrop.onPress?.()
+ expect(service.state).toBe('closed')
+ })
+
+ it('viewport press is the same outside interaction as the backdrop', () => {
+ const { service, connection } = build({ defaultOpen: true })
+ expect(connection.snapshot.parts.viewport['data-state']).toBe('open')
+ connection.snapshot.parts.viewport.onPress?.()
+ expect(service.state).toBe('closed')
+ })
+
+ it('backdrop press honors the onInteractOutside veto', () => {
+ const onInteractOutside = vi.fn((event?: PointerPayload) => event?.preventDefault?.())
+ const { service, connection } = build({ defaultOpen: true, onInteractOutside })
+
+ const payload: PointerPayload = {
+ defaultPrevented: false,
+ preventDefault() {
+ this.defaultPrevented = true
+ },
+ }
+ connection.snapshot.parts.backdrop.onPress?.(payload)
+
+ expect(onInteractOutside).toHaveBeenCalledTimes(1)
+ expect(service.state).toBe('open') // vetoed
+ })
+
+ it('close part closes; setOpen drives both directions', () => {
+ const { service, connection } = build({ defaultOpen: true })
+ connection.snapshot.parts.close.onPress?.()
+ expect(service.state).toBe('closed')
+
+ connection.snapshot.setOpen(true)
+ expect(service.state).toBe('open')
+ connection.snapshot.setOpen(false)
+ expect(service.state).toBe('closed')
+ })
+
+ it('parts expose data-state for styling/animation', () => {
+ const { connection } = build({ defaultOpen: true })
+ expect(connection.snapshot.parts.content['data-state']).toBe('open')
+ expect(connection.snapshot.parts.trigger['data-state']).toBe('open')
+ connection.snapshot.setOpen(false)
+ expect(connection.snapshot.parts.content['data-state']).toBe('closed')
+ })
+
+ it('visual layers carry data-placement; non-visual parts do not', () => {
+ const { connection } = build({ defaultOpen: true, placement: 'left' })
+ const { parts } = connection.snapshot
+ expect(parts.backdrop['data-placement']).toBe('left')
+ expect(parts.viewport['data-placement']).toBe('left')
+ expect(parts.content['data-placement']).toBe('left')
+ expect(parts.trigger['data-placement']).toBeUndefined()
+ expect(parts.close['data-placement']).toBeUndefined()
+ })
+
+ it('exposes the resolved placement on the api', () => {
+ const { connection } = build()
+ expect(connection.snapshot.placement).toBe('right')
+ })
+})
+
+describe('drawer connect — reactions', () => {
+ it('fires onOpenChange on every flip, not on subscribe', () => {
+ const onOpenChange = vi.fn()
+ const { service } = build({ onOpenChange })
+ expect(onOpenChange).not.toHaveBeenCalled()
+
+ service.send({ type: 'toggle' })
+ expect(onOpenChange).toHaveBeenLastCalledWith(true)
+ service.send({ type: 'escape' })
+ expect(onOpenChange).toHaveBeenLastCalledWith(false)
+ expect(onOpenChange).toHaveBeenCalledTimes(2)
+ })
+})
+
+describe('drawer machine — controlled', () => {
+ it('reports dismissal intents without moving the machine', () => {
+ const onOpenChange = vi.fn()
+ const { service } = build({ open: true, onOpenChange })
+ service.send({ type: 'escape' })
+ expect(service.state).toBe('open')
+ expect(onOpenChange).toHaveBeenLastCalledWith(false)
+ })
+
+ it('moves only on controlled.sync, and the prop echo is not reported back', () => {
+ const onOpenChange = vi.fn()
+ const { service } = build({ open: true, onOpenChange })
+ service.send({ type: 'controlled.sync', open: false })
+ expect(service.state).toBe('closed')
+ expect(onOpenChange).not.toHaveBeenCalled()
+ })
+
+ it('still gates dismissal intents before reporting them', () => {
+ const onOpenChange = vi.fn()
+ const { service } = build({ open: true, closeOnEscape: false, onOpenChange })
+ service.send({ type: 'escape' })
+ expect(service.state).toBe('open')
+ expect(onOpenChange).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/react/drawer/README.md b/packages/react/drawer/README.md
new file mode 100644
index 0000000..b668290
--- /dev/null
+++ b/packages/react/drawer/README.md
@@ -0,0 +1,40 @@
+# @dunky.dev/react-drawer
+
+React binding for [`@dunky.dev/drawer`](../../core/drawer): a compound
+component — `Drawer` plus its parts — that drives the framework-free drawer
+machine. The root owns the machine; parts translate the core's logical
+bindings into DOM attributes and handlers, and wire the DOM-only concerns
+(portal, focus trap, scroll lock, layer stack).
+
+Behavior contract: [`../../core/drawer/SPEC.md`](../../core/drawer/SPEC.md).
+React-specific surface: [SPEC.md](./SPEC.md).
+
+## Install
+
+```sh
+npm install @dunky.dev/react-drawer
+```
+
+## Usage
+
+```tsx
+import { Drawer } from '@dunky.dev/react-drawer'
+
+function BoardSettings() {
+ return (
+
+ Settings
+
+
+
+
+ Board settings
+ Configure this board.
+ Done
+
+
+
+
+ )
+}
+```
diff --git a/packages/react/drawer/SPEC.md b/packages/react/drawer/SPEC.md
new file mode 100644
index 0000000..6f4468f
--- /dev/null
+++ b/packages/react/drawer/SPEC.md
@@ -0,0 +1,137 @@
+# SPEC / React / Drawer
+
+The React implementation of the [core spec](../../core/drawer/SPEC.md).
+
+## Docs
+
+🔗 [`dunky.dev/ui/components/drawer`](https://dunky.dev/ui/components/drawer).
+
+## Install
+
+```sh
+npm install @dunky.dev/react-drawer
+```
+
+## Usage
+
+```tsx
+import { Drawer } from '@dunky.dev/react-drawer'
+;
+ Open
+
+
+
+
+ Title
+ Description
+ Close
+
+
+
+
+```
+
+React-specific notes on top of the core contract:
+
+- **`Portal`** teleports the layers to `document.body`, or to a `container`
+ you supply. Nothing is kept mounted while closed. When scoped to a
+ `container`, the scroll lock applies to that container instead of the page,
+ and the backdrop/viewport must be positioned `absolute` (not `fixed`) so the
+ overlay pins to the container.
+- **`Content`** renders the native `