diff --git a/.changeset/tidy-combobox-primitive.md b/.changeset/tidy-combobox-primitive.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/tidy-combobox-primitive.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/headless/package.json b/packages/headless/package.json
index 62dd0163c5b..d55b043f4aa 100644
--- a/packages/headless/package.json
+++ b/packages/headless/package.json
@@ -6,68 +6,72 @@
"type": "module",
"exports": {
"./accordion": {
- "import": "./dist/primitives/accordion/index.js",
- "types": "./dist/primitives/accordion/index.d.ts"
+ "types": "./dist/primitives/accordion/index.d.ts",
+ "import": "./dist/primitives/accordion/index.js"
},
"./button": {
- "import": "./dist/primitives/button/index.js",
- "types": "./dist/primitives/button/index.d.ts"
+ "types": "./dist/primitives/button/index.d.ts",
+ "import": "./dist/primitives/button/index.js"
},
"./tabs": {
- "import": "./dist/primitives/tabs/index.js",
- "types": "./dist/primitives/tabs/index.d.ts"
+ "types": "./dist/primitives/tabs/index.d.ts",
+ "import": "./dist/primitives/tabs/index.js"
},
"./tooltip": {
- "import": "./dist/primitives/tooltip/index.js",
- "types": "./dist/primitives/tooltip/index.d.ts"
+ "types": "./dist/primitives/tooltip/index.d.ts",
+ "import": "./dist/primitives/tooltip/index.js"
},
"./popover": {
- "import": "./dist/primitives/popover/index.js",
- "types": "./dist/primitives/popover/index.d.ts"
+ "types": "./dist/primitives/popover/index.d.ts",
+ "import": "./dist/primitives/popover/index.js"
},
"./select": {
- "import": "./dist/primitives/select/index.js",
- "types": "./dist/primitives/select/index.d.ts"
+ "types": "./dist/primitives/select/index.d.ts",
+ "import": "./dist/primitives/select/index.js"
},
"./menu": {
- "import": "./dist/primitives/menu/index.js",
- "types": "./dist/primitives/menu/index.d.ts"
+ "types": "./dist/primitives/menu/index.d.ts",
+ "import": "./dist/primitives/menu/index.js"
+ },
+ "./combobox": {
+ "types": "./dist/primitives/combobox/index.d.ts",
+ "import": "./dist/primitives/combobox/index.js"
},
"./autocomplete": {
- "import": "./dist/primitives/autocomplete/index.js",
- "types": "./dist/primitives/autocomplete/index.d.ts"
+ "types": "./dist/primitives/autocomplete/index.d.ts",
+ "import": "./dist/primitives/autocomplete/index.js"
},
"./collapsible": {
- "import": "./dist/primitives/collapsible/index.js",
- "types": "./dist/primitives/collapsible/index.d.ts"
+ "types": "./dist/primitives/collapsible/index.d.ts",
+ "import": "./dist/primitives/collapsible/index.js"
},
"./dialog": {
- "import": "./dist/primitives/dialog/index.js",
- "types": "./dist/primitives/dialog/index.d.ts"
+ "types": "./dist/primitives/dialog/index.d.ts",
+ "import": "./dist/primitives/dialog/index.js"
},
"./drawer": {
- "import": "./dist/primitives/drawer/index.js",
- "types": "./dist/primitives/drawer/index.d.ts"
+ "types": "./dist/primitives/drawer/index.d.ts",
+ "import": "./dist/primitives/drawer/index.js"
},
"./file-upload": {
- "import": "./dist/primitives/file-upload/index.js",
- "types": "./dist/primitives/file-upload/index.d.ts"
+ "types": "./dist/primitives/file-upload/index.d.ts",
+ "import": "./dist/primitives/file-upload/index.js"
},
"./flow": {
- "import": "./dist/primitives/flow/index.js",
- "types": "./dist/primitives/flow/index.d.ts"
+ "types": "./dist/primitives/flow/index.d.ts",
+ "import": "./dist/primitives/flow/index.js"
},
"./otp": {
- "import": "./dist/primitives/otp/index.js",
- "types": "./dist/primitives/otp/index.d.ts"
+ "types": "./dist/primitives/otp/index.d.ts",
+ "import": "./dist/primitives/otp/index.js"
},
"./hooks": {
- "import": "./dist/hooks/index.js",
- "types": "./dist/hooks/index.d.ts"
+ "types": "./dist/hooks/index.d.ts",
+ "import": "./dist/hooks/index.js"
},
"./utils": {
- "import": "./dist/utils/index.js",
- "types": "./dist/utils/index.d.ts"
+ "types": "./dist/utils/index.d.ts",
+ "import": "./dist/utils/index.js"
}
},
"scripts": {
diff --git a/packages/headless/src/primitives/combobox/README.md b/packages/headless/src/primitives/combobox/README.md
new file mode 100644
index 00000000000..03df4e759db
--- /dev/null
+++ b/packages/headless/src/primitives/combobox/README.md
@@ -0,0 +1,38 @@
+# Combobox
+
+A headless single-selection input. Typing searches options; choosing one remembers its value.
+
+```tsx
+import { Combobox } from '@clerk/headless/combobox';
+
+
+
+
+
+
+ item}
+ >
+ {item => (
+
+ {item}
+ ✓
+
+ )}
+
+
+
+;
+```
+
+`value`, `defaultValue`, and `onValueChange` control the selected option. Clearing the input clears selection with `null`. `inputValue`, `defaultInputValue`, and `onInputValueChange` control search text separately.
+
+Dismissal restores the selected label. Reopening shows all options until typing starts. Hover and keyboard highlighting do not change selection.
+
+For search inside another popup, use `inline` with `List` and bind `open` to the outer popup. Closing clears search without clearing selection. Supply `defaultInputValue` when an initial selection's label differs from its value.
+
+Combobox owns its selection state and rendering parts independently of Autocomplete. Both use general headless utilities for rendering, controllable state, and transitions. Mosaic adds styling separately.
diff --git a/packages/headless/src/primitives/combobox/combobox-arrow.tsx b/packages/headless/src/primitives/combobox/combobox-arrow.tsx
new file mode 100644
index 00000000000..5ebfa482e01
--- /dev/null
+++ b/packages/headless/src/primitives/combobox/combobox-arrow.tsx
@@ -0,0 +1,23 @@
+'use client';
+
+import { FloatingArrow, useMergeRefs } from '@floating-ui/react';
+import React from 'react';
+
+import { useComboboxContext } from './combobox-context';
+
+export type ComboboxArrowProps = Omit, 'context'>;
+
+export const ComboboxArrow = React.forwardRef(function ComboboxArrow(props, ref) {
+ const { floatingContext, arrowRef, placement } = useComboboxContext();
+ const mergedRef = useMergeRefs([arrowRef, ref]);
+ const side = placement.split('-')[0];
+
+ return (
+
+ );
+});
diff --git a/packages/headless/src/primitives/combobox/combobox-collection.tsx b/packages/headless/src/primitives/combobox/combobox-collection.tsx
new file mode 100644
index 00000000000..ed90c60195b
--- /dev/null
+++ b/packages/headless/src/primitives/combobox/combobox-collection.tsx
@@ -0,0 +1,20 @@
+'use client';
+
+import type { ReactNode } from 'react';
+
+import { useComboboxContext } from './combobox-context';
+
+export interface ComboboxCollectionProps- {
+ items: readonly Item[];
+ itemToStringLabel: (item: Item) => string;
+ children: (item: Item) => ReactNode;
+ empty?: ReactNode;
+}
+
+/** Filters items using the search query rather than the selected label. */
+export function ComboboxCollection
- ({ items, itemToStringLabel, children, empty }: ComboboxCollectionProps
- ) {
+ const { filterQuery } = useComboboxContext();
+ const query = filterQuery.trim().toLocaleLowerCase();
+ const filtered = query ? items.filter(item => itemToStringLabel(item).toLocaleLowerCase().includes(query)) : items;
+ return <>{filtered.length ? filtered.map(children) : empty}>;
+}
diff --git a/packages/headless/src/primitives/combobox/combobox-context.ts b/packages/headless/src/primitives/combobox/combobox-context.ts
new file mode 100644
index 00000000000..f8ac1d0d3fb
--- /dev/null
+++ b/packages/headless/src/primitives/combobox/combobox-context.ts
@@ -0,0 +1,51 @@
+import type {
+ ExtendedRefs,
+ FloatingContext,
+ Placement,
+ ReferenceType,
+ UseInteractionsReturn,
+} from '@floating-ui/react';
+import { createContext, type CSSProperties, useContext } from 'react';
+
+import type { TransitionProps } from '../../hooks/use-transition';
+
+export interface ComboboxContextValue {
+ open: boolean;
+ inputValue: string;
+ filterQuery: string;
+ selectedValue: string | null;
+ floatingContext: FloatingContext;
+ refs: ExtendedRefs;
+ floatingStyles: CSSProperties;
+ placement: Placement;
+ getReferenceProps: UseInteractionsReturn['getReferenceProps'];
+ getFloatingProps: UseInteractionsReturn['getFloatingProps'];
+ getItemProps: UseInteractionsReturn['getItemProps'];
+ activeIndex: number | null;
+ selectedIndex: number | null;
+ elementsRef: React.MutableRefObject>;
+ labelsRef: React.MutableRefObject>;
+ popupRef: React.RefObject;
+ triggerRef: React.MutableRefObject;
+ arrowRef: React.MutableRefObject;
+ valuesByIndexRef: React.MutableRefObject