diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts
index e6dd1cd2f..1b13e3e41 100644
--- a/.storybook/components/Roadmap/data.ts
+++ b/.storybook/components/Roadmap/data.ts
@@ -412,7 +412,8 @@ export const rows: Rows = [
},
{
component: 'DropdownMenu',
- status: '🚧 Planned',
+ status: '✅ Done',
+ stage: '🔵 experimental',
planned: 'Q3 2026',
},
{
diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.mdx b/packages/components/src/components/DropdownMenu/DropdownMenu.mdx
new file mode 100644
index 000000000..23d841ace
--- /dev/null
+++ b/packages/components/src/components/DropdownMenu/DropdownMenu.mdx
@@ -0,0 +1,231 @@
+import {
+ Meta,
+ Story,
+ Props,
+ Status,
+} from '../../../../../.storybook/components';
+
+import * as Stories from './DropdownMenu.stories';
+
+
+
+# DropdownMenu
+
+
+
+A dropdown menu displays a list of actions or options that a user can choose.
+
+Compared to the [Menu](?path=/docs/components-menu--docs) component, it adds
+submenus and search.
+
+## Import
+
+```tsx
+import { DropdownMenu } from '@koobiq/react-components';
+```
+
+## Usage
+
+A dropdown menu is composed of a trigger and its content:
+
+
+
+## Props
+
+
+
+## Subcomponents
+
+The component has the following helper components:
+
+- `DropdownMenu.Popover` — the overlay, also used for a submenu
+- `DropdownMenu.Content` — the list of items inside the popover
+- `DropdownMenu.Item` — a single action
+- `DropdownMenu.ItemText` — the text of an item, with an optional caption
+- `DropdownMenu.ItemAddon` — an addon of an item, such as an icon or a shortcut
+- `DropdownMenu.Section` — a group of items
+- `DropdownMenu.Header` — a block of custom content, or the heading of a section
+- `DropdownMenu.SubmenuTrigger` — wraps an item and the submenu it opens
+- `DropdownMenu.Autocomplete` — filters the menu with a field
+- `DropdownMenu.Footer` — the text under the items, outside the scroll area
+- `DropdownMenu.Pressable` — makes custom markup usable as a trigger
+
+The popover and the content are separate components, so anything can go between
+them. That is how the search field is added.
+
+## Trigger
+
+The first child of the `DropdownMenu` is the trigger. A `Button` works as one
+without extra props.
+
+For custom markup that is not a button, wrap it in the `DropdownMenu.Pressable`
+(see the [Separators](#separators) example).
+
+The `trigger` prop switches between opening on press (default) and on a long press:
+
+
+
+## Content
+
+The items can be written out one by one, or passed as data through the `items`
+prop. Give every item a unique `id`. When an item is chosen, the `onAction`
+handler is called with that id.
+
+
+
+## Item content
+
+An item is composed of slots: the `DropdownMenu.ItemAddon` for icons and
+shortcuts, and the `DropdownMenu.ItemText` for the label and its caption.
+
+
+
+An item built from slots needs the `textValue` prop, because the search and the
+typeahead match on it. Without it the item cannot be found by typing, and it
+disappears on the first keystroke in a searchable menu. The component warns
+about this in development.
+
+Add the `aria-label` prop when an item needs a shorter name for screen readers
+than all of its slots read together.
+
+The `align` prop lines the slots up by their top edge instead of centring them.
+It is useful for a tall item with a caption.
+
+## Selection
+
+The items are not selectable by default. Selection is turned on with the
+`selectionMode` prop. Use the `defaultSelectedKeys` prop to set the selected
+items (uncontrolled), and the `selectedKeys` prop to control them. Both take the
+`id` of the items.
+
+### Single
+
+
+
+### Multiple
+
+
+
+## Disabled items
+
+Items can be marked as disabled with the `disabledKeys` prop of the content, or
+with the `isDisabled` prop of a single item.
+
+
+
+## Links
+
+An item with the `href` prop becomes a link. It navigates instead of calling the
+`onAction` handler, and it is never selectable.
+
+
+
+## Client side routing
+
+Wrap the app in the `RouterProvider`, and the link items will navigate through
+your router, be it **Next.js**, **React Router** or another one.
+
+```tsx
+import { useRouter } from 'next/navigation';
+
+import { RouterProvider } from '@koobiq/react-components';
+
+export default function App() {
+ const router = useRouter();
+
+ return (
+
+
+
+
+
+ Page 1
+ Page 2
+
+
+
+
+ );
+}
+```
+
+## Sections
+
+Groups of items can be wrapped in a `DropdownMenu.Section` with the `title` prop.
+
+### Static items
+
+
+
+### Dynamic items
+
+
+
+### With Section Level Selection
+
+Each section takes its own `selectionMode`, `selectedKeys` and `onSelectionChange`
+props, so one menu can mix plain actions with a single and a multiple choice.
+
+
+
+## Separators
+
+Put a `Divider` between items or sections to group them without a title. The
+`DropdownMenu.Header` renders custom content inside the menu.
+
+
+
+## Submenu
+
+Wrap an item and a nested `DropdownMenu.Popover` in a `DropdownMenu.SubmenuTrigger`
+to give the item a submenu. The chevron is added automatically. A submenu opens
+on hover after the `delay` prop (200 ms by default), on ArrowRight and
+on press, and closes on ArrowLeft. Submenus can be nested.
+
+
+
+## Search
+
+Wrap the content in a `DropdownMenu.Autocomplete` and put a `SearchInput` above
+it. The field has to be next to the menu, not inside it.
+
+Matching ignores the case and the accents. Pass the `filter` prop to change it.
+The focus stays in the field while the arrows move through the items. An item
+that opens a submenu stays when its own text matches, but the items inside a
+submenu are not searched.
+
+
+
+The `renderEmptyState` prop of the content replaces the default message, with an
+`EmptyState` for instance.
+
+
+
+## Footer
+
+The `DropdownMenu.Footer` shows text under the items. It stays in place while the
+items scroll.
+
+
+
+## Placement
+
+The placement of the menu with respect to its trigger can be adjusted using the
+`placement` prop of the popover. The menu also flips to the opposite side when
+there is not enough space.
+
+
+
+## Open
+
+### Default open
+
+The menu isn't opened by default. The `defaultOpen` prop can be used to set the
+default state.
+
+### Controlled open
+
+The `isOpen` prop can be used to make the opened state controlled. The
+`onOpenChange` event is fired when the open state of the menu changes.
+
+
diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx b/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx
new file mode 100644
index 000000000..1040bc4c3
--- /dev/null
+++ b/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx
@@ -0,0 +1,713 @@
+import type { CSSProperties } from 'react';
+import { useState } from 'react';
+
+import { useBoolean } from '@koobiq/react-core';
+import {
+ IconAlignCenter16,
+ IconAlignLeft16,
+ IconAlignRight16,
+ IconArrowRightToBracket16,
+ IconBell16,
+ IconFileMultipleO16,
+ IconDashboard16,
+ IconGear16,
+ IconMessage16,
+ IconPlus16,
+ IconScissors16,
+ IconTextBold16,
+ IconTextItalic16,
+ IconTextUnderline16,
+ IconTrash16,
+} from '@koobiq/react-icons';
+import type { Meta, StoryObj } from '@storybook/react';
+
+import type { Selection } from '../../index';
+import { Button } from '../Button';
+import { Divider } from '../Divider';
+import { EmptyState } from '../EmptyState';
+import { FlexBox } from '../FlexBox';
+import { spacing } from '../layout';
+import { SearchInput } from '../SearchInput';
+import { SelectNext as Select } from '../SelectNext';
+import { Typography } from '../Typography';
+
+import avatar from './__stories__/avatar.webp';
+import { DropdownMenu } from './DropdownMenu';
+import type { DropdownMenuProps, DropdownMenuPropPlacement } from './index';
+import { dropdownMenuPropPlacement } from './index';
+
+const meta = {
+ title: 'Components/DropdownMenu',
+ component: DropdownMenu,
+ subcomponents: {
+ 'DropdownMenu.Popover': DropdownMenu.Popover,
+ 'DropdownMenu.Content': DropdownMenu.Content,
+ 'DropdownMenu.Item': DropdownMenu.Item,
+ 'DropdownMenu.ItemText': DropdownMenu.ItemText,
+ 'DropdownMenu.ItemAddon': DropdownMenu.ItemAddon,
+ 'DropdownMenu.Section': DropdownMenu.Section,
+ 'DropdownMenu.Header': DropdownMenu.Header,
+ 'DropdownMenu.SubmenuTrigger': DropdownMenu.SubmenuTrigger,
+ 'DropdownMenu.Autocomplete': DropdownMenu.Autocomplete,
+ 'DropdownMenu.Footer': DropdownMenu.Footer,
+ },
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['status:new', 'date:2026-08-06'],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Base: Story = {
+ render: (args) => (
+
+
+
+ alert(key)}>
+ New
+ Open
+ Save
+ Duplicate
+ Rename
+
+
+
+ ),
+};
+
+export const Content: Story = {
+ render: function Render(args) {
+ const items = [
+ { id: 'new', name: 'New' },
+ { id: 'open', name: 'Open' },
+ { id: 'save', name: 'Save' },
+ { id: 'duplicate', name: 'Duplicate' },
+ { id: 'rename', name: 'Rename' },
+ ];
+
+ return (
+
+
+
+ alert(key)}>
+ {(item: (typeof items)[number]) => (
+ {item.name}
+ )}
+
+
+
+ );
+ },
+};
+
+export const ItemContent: Story = {
+ render: (args) => (
+
+
+
+ alert(key)}>
+
+
+
+
+
+ Copy
+
+
+
+ ⌘C
+
+
+
+
+
+
+
+
+ Cut
+
+
+
+ ⌘X
+
+
+
+
+
+
+
+
+ Delete
+
+
+
+ ⌫
+
+
+
+
+
+
+ ),
+};
+
+export const SelectionSingle: Story = {
+ render: function Render(args) {
+ const [selectedKeys, setSelectedKeys] = useState(
+ new Set(['medium'])
+ );
+
+ return (
+
+
+
+
+ Small
+ Medium
+ Large
+
+
+
+ );
+ },
+};
+
+export const SelectionMultiple: Story = {
+ render: function Render(args) {
+ const [selectedKeys, setSelectedKeys] = useState(
+ new Set(['sidebar'])
+ );
+
+ return (
+
+
+
+
+ Sidebar
+ Search bar
+ Console
+
+
+
+ );
+ },
+};
+
+export const DisabledItems: Story = {
+ render: (args) => (
+
+
+
+ alert(key)}
+ >
+ New
+ Open
+
+ Save
+
+ Rename
+
+
+
+ ),
+};
+
+export const Links: Story = {
+ render: (args) => (
+
+
+
+
+
+ Koobiq React
+
+
+ Koobiq
+
+
+ GitHub
+
+
+
+
+ ),
+};
+
+export const Sections: Story = {
+ render: (args) => (
+
+
+
+ alert(key)}>
+
+ New
+ Open
+
+
+
+ Copy
+ Cut
+ Paste
+
+
+
+
+ ),
+};
+
+export const SectionsDynamic: Story = {
+ render: function Render(args) {
+ // A separator is its own entry: the collection expects one node per item.
+ const sections = [
+ {
+ id: 'file',
+ name: 'File',
+ children: [
+ { id: 'new', name: 'New' },
+ { id: 'open', name: 'Open' },
+ ],
+ },
+ { id: 'file-separator' },
+ {
+ id: 'edit',
+ name: 'Edit',
+ children: [
+ { id: 'copy', name: 'Copy' },
+ { id: 'cut', name: 'Cut' },
+ { id: 'paste', name: 'Paste' },
+ ],
+ },
+ ];
+
+ return (
+
+
+
+ alert(key)}>
+ {(section: (typeof sections)[number]) =>
+ 'children' in section ? (
+
+ {(item: { id: string; name: string }) => (
+ {item.name}
+ )}
+
+ ) : (
+
+ )
+ }
+
+
+
+ );
+ },
+};
+
+export const WithSectionLevelSelection: Story = {
+ render: function Render(args) {
+ const [style, setStyle] = useState(new Set(['bold', 'italic']));
+ const [align, setAlign] = useState(new Set(['left']));
+
+ return (
+
+
+
+
+
+
+
+
+
+ Cut
+
+
+ ⌘X
+
+
+
+
+
+
+
+ Copy
+
+
+ ⌘C
+
+
+
+
+
+
+
+
+
+
+ Bold
+
+
+
+
+
+ Italic
+
+
+
+
+
+ Underline
+
+
+
+
+
+
+
+
+ Left
+
+
+
+
+
+ Center
+
+
+
+
+
+ Right
+
+
+
+
+
+ );
+ },
+};
+
+export const Separators: Story = {
+ render: function Render(args) {
+ const buttonStyle: CSSProperties = {
+ width: 48,
+ height: 48,
+ borderRadius: '50%',
+ overflow: 'hidden',
+ padding: 0,
+ border: 'none',
+ background: 'transparent',
+ cursor: 'pointer',
+ };
+
+ const imgStyle: CSSProperties = {
+ width: '100%',
+ height: '100%',
+ objectFit: 'cover',
+ display: 'block',
+ };
+
+ return (
+
+
+