diff --git a/.changeset/combobox.md b/.changeset/combobox.md
new file mode 100644
index 0000000..67b01c8
--- /dev/null
+++ b/.changeset/combobox.md
@@ -0,0 +1,46 @@
+---
+'@dunky.dev/combobox': minor
+'@dunky.dev/react-combobox': minor
+---
+
+Add the Combobox primitive — an editable combobox with list autocomplete
+following the WAI-ARIA APG pattern, shipped as an agnostic core
+(`@dunky.dev/combobox`) plus a React binding (`@dunky.dev/react-combobox`).
+
+DOM focus stays in the input the whole time (`aria-activedescendant` tracks
+the highlighted suggestion), items register themselves from the render tree,
+and the machine owns navigation, highlighting, and selection. Filtering is
+the consumer's: render the Items that match the input text — the machine
+navigates whatever is rendered, keeping arrow-key order aligned with the
+rendered order even as filtering unmounts, remounts, or re-sorts items in
+place. Value, input
+text, and open state are each controlled or uncontrolled; every change is
+reported through `onValueChange` / `onInputValueChange` / `onOpenChange` /
+`onHighlightChange`. The open listbox joins the shared overlay layer stack,
+so Escape and outside interactions dismiss one layer at a time across
+primitives.
+
+```tsx
+import { useState } from 'react'
+import { Combobox } from '@dunky.dev/react-combobox'
+
+const fruits = ['Apple', 'Banana', 'Cherry']
+
+function App() {
+ const [query, setQuery] = useState('')
+ const matches = fruits.filter(fruit => fruit.toLowerCase().includes(query.toLowerCase()))
+ return (
+
+
+ ▾
+
+ {matches.map(fruit => (
+
+ {fruit} ✓
+
+ ))}
+
+
+ )
+}
+```
diff --git a/packages/core/combobox/README.md b/packages/core/combobox/README.md
new file mode 100644
index 0000000..57d95be
--- /dev/null
+++ b/packages/core/combobox/README.md
@@ -0,0 +1,32 @@
+# @dunky.dev/combobox
+
+The framework-agnostic combobox 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/combobox
+```
+
+## Usage
+
+```ts
+import { machine, connector } from '@dunky.dev/state-machine'
+import { comboboxMachine, comboboxConnect } from '@dunky.dev/combobox'
+
+// `id` is substrate-minted (SSR-safe); the connect derives the per-part ids.
+const service = machine(comboboxMachine({ ...options, id: 'my-combobox' }))
+connector(service, comboboxConnect, options) // wires the consumer callbacks
+service.start()
+
+// The substrate reports the rendered suggestions; the machine navigates them.
+service.send({ type: 'item.register', item: { value: 'apple', label: 'Apple', disabled: false } })
+service.send({ type: 'input', value: 'ap' }) // typing opens the list
+service.send({ type: 'highlight.next' })
+service.send({ type: 'select' }) // commits: value 'apple', input text 'Apple'
+```
diff --git a/packages/core/combobox/SPEC.md b/packages/core/combobox/SPEC.md
new file mode 100644
index 0000000..bdb6272
--- /dev/null
+++ b/packages/core/combobox/SPEC.md
@@ -0,0 +1,195 @@
+# SPEC / Combobox
+
+## Reference
+
+- **W3C pattern**: [APG Combobox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/),
+ the editable variant with list autocomplete and manual selection, over the
+ normative
+ [WAI-ARIA 1.2 `combobox` / `listbox` / `option`](https://www.w3.org/TR/wai-aria-1.2/#combobox)
+ definitions.
+- **State machine**: built on `@dunky.dev/state-machine`.
+- **Prior art**: API shape modeled on the Ark and Base UI comboboxes (Radix
+ has none).
+- **Layering**: dismissal routing follows the shared overlay layer stack —
+ see `@dunky.dev/dom-layer-stack`.
+
+## Overview
+
+A combobox is a text input paired with a listbox of suggestions: the user
+types freely, the list narrows to what matches, and picking a suggestion
+commits it as the value. It is the home for search-and-pick interactions —
+choosing a country, assigning a user, jumping to a file — where the option
+set is too large to scan and the user knows (part of) what they're looking
+for. The input text and the committed value are two different things: text is
+free, the value only ever comes from a suggestion.
+
+## Anatomy
+
+```
+ — root; owns value + input text + open state, renders
+ | nothing of its own
+ |_ — the text field; DOM focus lives here the whole time
+ |_ — optional disclosure button; not in the tab order
+ |_ — the popup list of suggestions
+ |_ — one suggestion; registers itself with the machine
+ |_ — the "this one is chosen" mark, rendered only
+ inside the selected item
+```
+
+## Behavior
+
+Using the combobox is a walkthrough of intent, not a prop list:
+
+- The **root** owns three pieces of state, each exposed controlled and
+ uncontrolled: the value (which suggestion is chosen), the input text, and
+ the open state. Every change to any of them — whatever caused it — is
+ reported back through its callback so a controlled consumer stays in sync.
+- **Typing** updates the input text, opens the list, and clears the highlight
+ (the suggestion set is about to change). Filtering is the consumer's
+ responsibility: they decide which Items to render from the input text — the
+ machine never filters, it navigates whatever is rendered.
+- **Arrow keys** open the list when it is closed — ArrowDown starting the
+ highlight from the selected suggestion (when rendered and enabled) else the
+ first enabled one, ArrowUp from the selection else the last. While open they
+ move the highlight across enabled suggestions: disabled ones are skipped,
+ and the `loop` option decides whether the ends wrap around. Moving the
+ pointer over an enabled suggestion moves the highlight there too — keyboard
+ and pointer drive the same highlight.
+- **Selecting** — Enter on the highlighted suggestion, or pressing one —
+ commits: the value becomes the item's value, the input text becomes its
+ label, and the list closes. Enter with nothing highlighted just closes.
+ Free-typed text never commits itself: the value only ever comes from an
+ item.
+- The **trigger** is an optional disclosure button that toggles the list. It
+ is not a tab stop — the input is the one place keyboard focus lives.
+- **Escape** closes without selecting; so does any interaction outside the
+ combobox — a press, or focus moving out (Tab included). The consumer can
+ veto a single occurrence of either from its handler.
+- **Items** are not configuration: each suggestion registers itself with the
+ machine (value, label, disabled) when it appears, re-registers when its
+ label, disabled state, or rendered position changes, and unregisters when
+ it goes away — so the suggestion list always mirrors what is actually
+ rendered, keystroke by keystroke. A disabled item is skipped by navigation,
+ cannot be highlighted, and pressing it does nothing.
+- A **disabled** combobox never opens, by any input.
+
+The combobox participates in the shared overlay layer stack
+(`@dunky.dev/dom-layer-stack`) while open: a combobox opened from within
+another overlay — a dialog, a popover — stacks on top of it, Escape unwinds
+one layer per press, and an interaction inside a nested layer never counts as
+outside the one beneath. Being registered is also what keeps the open listbox
+reachable when it floats above a modal layer.
+
+## States
+
+| State | Behavior |
+| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `closed` | Only the input (and trigger) are interactive. Open intents — typing, arrow keys, trigger press, imperative open — move to `open` unless the combobox is disabled. |
+| `open` | The listbox shows and a highlight tracks the active suggestion. Navigation moves the highlight; selection commits and closes; dismissal closes without selecting. |
+
+### Highlight
+
+The highlight exists only while open, and unlike a select it is optional —
+the resting state while the user types is "nothing highlighted". It is seeded
+by the arrow key that opens the list, moved by navigation and pointer, cleared
+by typing, and cleared on every close. The highlighted suggestion
+unregistering (filtered out from under the highlight) clears it, as does
+re-registering as disabled.
+
+### Input text
+
+The input text is free-form and belongs to the user until a selection
+commits: typing and controlled updates are the only things that change it
+besides selection (which replaces it with the chosen item's label). Closing
+without selecting — Escape, outside interaction — leaves the text exactly as
+typed; syncing a controlled value from outside doesn't touch it either.
+
+## Accessibility
+
+Per the APG editable combobox with list autocomplete:
+
+- **Roles**: the input is a `combobox` with `aria-autocomplete="list"`,
+ `aria-expanded`, and `aria-controls` naming the listbox. The popup is a
+ `listbox`; every item is an `option` carrying `aria-selected`, and
+ `aria-disabled` when disabled. The trigger carries
+ `aria-haspopup="listbox"` and mirrors `aria-expanded`.
+- **Focus**: DOM focus stays in the input the whole time — the listbox is
+ never focused. While open, `aria-activedescendant` on the input names the
+ highlighted option's id, and is absent while nothing is highlighted. No
+ focus moves on open/close means closing never needs a focus restore.
+- **Keyboard** (on the input, since focus never leaves it):
+
+ | Key | Closed | Open |
+ | ------------------- | -------------------------------------------------- | -------------------------------------------- |
+ | Printable / editing | edits the text, opens the list | edits the text, clears the highlight |
+ | ArrowDown | opens, highlights the selection else first enabled | moves the highlight down (wraps when `loop`) |
+ | ArrowUp | opens, highlights the selection else last enabled | moves the highlight up (wraps when `loop`) |
+ | Enter | — (native: submits the form) | selects the highlighted suggestion, closes |
+ | Escape | — | closes without selecting |
+ | Home / End | native caret movement | native caret movement — never the highlight |
+ | Tab | moves focus on | focus out is an outside interaction — closes |
+
+- **Disabled**: a disabled combobox exposes `aria-disabled`; whether the
+ input also refuses text entry is the substrate's native concern.
+- **Hidden while closed**: the listbox stays rendered (suggestions keep their
+ registration and ids stable) but is removed from the accessibility tree.
+
+## Constraints
+
+- The value only ever comes from a registered, enabled item — free-typed
+ input text never becomes the value.
+- A disabled item can never be selected or highlighted, by any input.
+- `aria-activedescendant` only ever references an option that is rendered and
+ highlighted; it is absent while closed or while nothing is highlighted.
+- Item values must be unique within one combobox and contain no whitespace —
+ option ids are derived from them.
+- Navigation order always matches the rendered order of the suggestions, even
+ as the consumer's filtering unmounts and remounts items between keystrokes —
+ or a keyed re-sort moves them in place without unmounting anything.
+- Every value, input-text, open, and highlight change is reported: whatever
+ its cause, the matching callback fires — value, then input text, then
+ highlight, then open within one interaction.
+- The machine never filters: which suggestions exist is decided entirely by
+ what the consumer renders.
+- Out of scope for v0, on purpose: multiple selection; committing free-typed
+ values (the input text is free, the value is not); inline autocomplete
+ (`aria-autocomplete` is always `list`); virtualization; a positioning
+ engine or portal part (the consumer positions the popup — `data-state` is
+ the styling hook); scrolling the highlighted option into view; a Label part
+ (label the input via `aria-label`/`aria-labelledby` or a native `