diff --git a/docs/src/app/(private)/experiments/composite.module.css b/docs/src/app/(private)/experiments/composite.module.css
new file mode 100644
index 0000000000..1ba9a65f80
--- /dev/null
+++ b/docs/src/app/(private)/experiments/composite.module.css
@@ -0,0 +1,46 @@
+.Grid {
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ padding-block: 0.25rem;
+ background-color: canvas;
+ color: oklch(12% 5% 264 / 90%);
+ transform-origin: var(--transform-origin);
+ transition:
+ transform 150ms,
+ opacity 150ms;
+
+ &[data-starting-style],
+ &[data-ending-style] {
+ opacity: 0;
+ transform: scale(0.9);
+ }
+
+ outline: 1px solid oklch(12% 9% 264 / 8%);
+ box-shadow:
+ 0px 4px 6px -3px oklch(12% 9% 264 / 8%),
+ 0px 4px 6px -4px oklch(12% 9% 264 / 8%);
+}
+
+.GridRow {
+ display: flex;
+}
+
+.GridItem {
+ outline: 0;
+ cursor: default;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 48px;
+ height: 48px;
+ &:hover {
+ background: oklch(12% 9.5% 264 / 5%);
+ }
+
+ &[data-highlighted] {
+ z-index: 0;
+ position: relative;
+ background: oklch(12% 9.5% 264 / 5%);
+ }
+}
diff --git a/docs/src/app/(private)/experiments/composite.tsx b/docs/src/app/(private)/experiments/composite.tsx
new file mode 100644
index 0000000000..af0b114e35
--- /dev/null
+++ b/docs/src/app/(private)/experiments/composite.tsx
@@ -0,0 +1,81 @@
+'use client';
+import * as React from 'react';
+import { Composite } from '@base-ui-components/react/composite';
+import styles from './composite.module.css';
+
+export default function ExampleGrid() {
+ const [column, setColumn] = React.useState(0);
+ const [row, setRow] = React.useState(0);
+
+ const rootHandleHighligtedIndexChange = (index: number) => {
+ setRow(index);
+ };
+
+ const handleHighligtedIndexChange = (index: number) => {
+ setColumn(index);
+ };
+
+ return (
+
+
+
+ A
+ B
+ C
+ D
+
+
+
+
+ E
+ F
+ G
+ H
+
+
+
+
+ I
+ J
+ K
+ L
+
+
+
+
+ M
+ N
+
+
+
+ );
+}
diff --git a/docs/src/app/(private)/experiments/menu-fluent-ui.module.css b/docs/src/app/(private)/experiments/menu-fluent-ui.module.css
new file mode 100644
index 0000000000..9b3d9da892
--- /dev/null
+++ b/docs/src/app/(private)/experiments/menu-fluent-ui.module.css
@@ -0,0 +1,174 @@
+.Button {
+ width: fit-content;
+ box-sizing: border-box;
+ font-size: 0.875rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.375rem;
+ height: 2rem;
+ padding: 0 1rem;
+ margin: 0;
+ outline: 0;
+ border: 1px solid oklch(12% 9% 264 / 8%);
+ background-color: oklch(98% 0.25% 264);
+ font-family: inherit;
+ font-weight: 500;
+ line-height: 1.5rem;
+ color: oklch(12% 5% 264 / 90%);
+ user-select: none;
+
+ @media (hover: hover) {
+ &:hover {
+ background-color: oklch(12% 9.5% 264 / 5%);
+ }
+ }
+
+ &:active {
+ background-color: oklch(12% 9.5% 264 / 5%);
+ }
+
+ &[data-popup-open] {
+ background-color: oklch(12% 9.5% 264 / 5%);
+ }
+
+ &:focus-visible {
+ outline: 2px solid oklch(45% 50% 264);
+ outline-offset: -1px;
+ }
+}
+
+.Positioner {
+ outline: 0;
+}
+
+.Popup,
+.Grid {
+ box-sizing: border-box;
+ padding-block: 0.25rem;
+ background-color: canvas;
+ color: oklch(12% 5% 264 / 90%);
+ transform-origin: var(--transform-origin);
+ transition:
+ transform 150ms,
+ opacity 150ms;
+
+ &[data-starting-style],
+ &[data-ending-style] {
+ opacity: 0;
+ transform: scale(0.9);
+ }
+
+ outline: 1px solid oklch(12% 9% 264 / 8%);
+ box-shadow:
+ 0px 4px 6px -3px oklch(12% 9% 264 / 8%),
+ 0px 4px 6px -4px oklch(12% 9% 264 / 8%);
+}
+
+.Arrow {
+ display: flex;
+
+ &[data-side='top'] {
+ bottom: -8px;
+ rotate: 180deg;
+ }
+ &[data-side='bottom'] {
+ top: -8px;
+ rotate: 0deg;
+ }
+ &[data-side='left'] {
+ right: -13px;
+ rotate: 90deg;
+ }
+ &[data-side='right'] {
+ left: -13px;
+ rotate: -90deg;
+ }
+}
+
+.ArrowFill {
+ fill: canvas;
+}
+
+.ArrowOuterStroke {
+ fill: oklch(12% 9% 264 / 8%);
+}
+
+.Separator {
+ margin: 0.375rem 0;
+ height: 1px;
+ background-color: oklch(12% 9% 264 / 8%);
+}
+
+.Item,
+.ItemWithSubmenu,
+.SubmenuTriggerInsideItem,
+.SplitButtonItem,
+.SplitButtonSubmenuTrigger {
+ outline: 0;
+ cursor: default;
+ user-select: none;
+ padding-block: 0.5rem;
+ padding-left: 1rem;
+ padding-right: 2rem;
+ display: flex;
+ font-size: 0.875rem;
+ line-height: 1rem;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding-right: 1rem;
+
+ &[data-highlighted] {
+ z-index: 0;
+ position: relative;
+ background: oklch(12% 9.5% 264 / 5%);
+ }
+}
+
+.ItemWithSubmenu {
+ padding-right: 0;
+ padding-block: 0;
+}
+
+.SubmenuTrigger {
+ padding: 0.75rem 1rem;
+ margin-left: auto;
+}
+
+.SplitButtonItem {
+ display: inline-flex;
+ padding-left: 1rem;
+}
+
+.SplitButtonSubmenuTrigger {
+ padding-top: 0.75rem;
+ padding-bottom: 0.75rem;
+ display: inline-flex;
+}
+
+.Grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 0.25rem;
+}
+
+.GridRow {
+ display: flex;
+}
+
+.GridItem {
+ outline: 0;
+ cursor: default;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 48px;
+ height: 48px;
+
+ &[data-highlighted] {
+ z-index: 0;
+ position: relative;
+ background: oklch(12% 9.5% 264 / 5%);
+ }
+}
diff --git a/docs/src/app/(private)/experiments/menu-fluent-ui.tsx b/docs/src/app/(private)/experiments/menu-fluent-ui.tsx
new file mode 100644
index 0000000000..ad1e84011f
--- /dev/null
+++ b/docs/src/app/(private)/experiments/menu-fluent-ui.tsx
@@ -0,0 +1,159 @@
+'use client';
+import * as React from 'react';
+import { Menu } from '@base-ui-components/react/menu';
+import styles from './menu-fluent-ui.module.css';
+
+export default function ExampleMenu() {
+ return (
+
+
+
+
+ Contextual menu
+
+
+
+
+ {/* Submenu */}
+
+
+ New{' '}
+
+
+
+
+
+
+
+ Email message
+ Calendar event
+
+
+
+
+
+ {/* Submenu */}
+
+
+ Share{' '}
+
+
+
+
+
+
+
+ Share to Twitter
+
+ Share to Facebook
+
+
+
+
+
+
+ Share w/ split
+
+
+
+
+
+
+
+
+ Share to Twitter
+
+ Share to Facebook
+
+
+
+
+
+
+
+
+
+
+
+
+ Contextual menu
+
+
+
+
+ New
+ Upload
+
+
+
+ Charm
+
+
+
+
+
+
+
+ {[...Array(16).keys()].map((i) => (
+
+ {i + 1}
+
+ ))}
+
+
+
+
+
+
+ Categorized
+
+
+
+
+
+
+
+ Share to Twitter
+
+ Share to Facebook
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function ChevronDownIcon(props: React.ComponentProps<'svg'>) {
+ return (
+
+ );
+}
+
+function ChevronRightIcon(props: React.ComponentProps<'svg'>) {
+ return (
+
+ );
+}
diff --git a/packages/react/src/composite/index.parts.ts b/packages/react/src/composite/index.parts.ts
new file mode 100644
index 0000000000..e42456f7d9
--- /dev/null
+++ b/packages/react/src/composite/index.parts.ts
@@ -0,0 +1,3 @@
+export { CompositeRoot as Root } from './root/CompositeRoot';
+export { CompositeList as List } from './list/CompositeList';
+export { CompositeItem as Item } from './item/CompositeItem';
diff --git a/packages/react/src/composite/index.ts b/packages/react/src/composite/index.ts
new file mode 100644
index 0000000000..5709e5228e
--- /dev/null
+++ b/packages/react/src/composite/index.ts
@@ -0,0 +1 @@
+export * as Composite from './index.parts';
diff --git a/packages/react/src/menu/root/MenuRoot.tsx b/packages/react/src/menu/root/MenuRoot.tsx
index ce482cfe37..a7245ca58e 100644
--- a/packages/react/src/menu/root/MenuRoot.tsx
+++ b/packages/react/src/menu/root/MenuRoot.tsx
@@ -26,6 +26,7 @@ const MenuRoot: React.FC = function MenuRoot(props) {
orientation = 'vertical',
delay = 100,
openOnHover: openOnHoverProp,
+ cols,
} = props;
const direction = useDirection();
@@ -54,6 +55,7 @@ const MenuRoot: React.FC = function MenuRoot(props) {
delay,
onTypingChange,
modal,
+ cols,
});
const context: MenuRootContext = React.useMemo(
@@ -141,6 +143,7 @@ namespace MenuRoot {
* Defaults to `true` for nested menus.
*/
openOnHover?: boolean;
+ cols?: number;
}
}
diff --git a/packages/react/src/menu/root/useMenuRoot.ts b/packages/react/src/menu/root/useMenuRoot.ts
index 058d927800..4844b5f3ed 100644
--- a/packages/react/src/menu/root/useMenuRoot.ts
+++ b/packages/react/src/menu/root/useMenuRoot.ts
@@ -44,6 +44,7 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret
openOnHover,
onTypingChange,
modal,
+ cols,
} = parameters;
const [triggerElement, setTriggerElement] = React.useState(null);
@@ -192,6 +193,7 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret
rtl: direction === 'rtl',
disabledIndices: EMPTY_ARRAY,
onNavigate: setActiveIndex,
+ cols,
});
const typeahead = useTypeahead(floatingRootContext, {
@@ -287,7 +289,7 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret
);
}
-export type MenuOrientation = 'horizontal' | 'vertical';
+export type MenuOrientation = 'horizontal' | 'vertical' | 'both';
export namespace useMenuRoot {
export interface Parameters {
@@ -347,6 +349,7 @@ export namespace useMenuRoot {
*/
onTypingChange: (typing: boolean) => void;
modal: boolean;
+ cols?: number;
}
export interface ReturnValue {