diff --git a/docs/manifest.json b/docs/manifest.json
index ba345e7716ee3..5906743512062 100644
--- a/docs/manifest.json
+++ b/docs/manifest.json
@@ -785,6 +785,12 @@
"markdown_source": "../packages/components/src/confirm-dialog/README.md",
"parent": "components"
},
+ {
+ "title": "CustomSelectControlV2",
+ "slug": "custom-select-control-v2",
+ "markdown_source": "../packages/components/src/custom-select-control-v2/README.md",
+ "parent": "components"
+ },
{
"title": "CustomSelectControl",
"slug": "custom-select-control",
diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index eb6e595e304ec..135d8cb8c64ac 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -6,6 +6,10 @@
- `Tabs`: Memoize and expose the component context ([#56224](https://github.com/WordPress/gutenberg/pull/56224)).
+### Internal
+
+- Introduce experimental new version of `CustomSelectControl` based on `ariakit` ([#55790](https://github.com/WordPress/gutenberg/pull/55790))
+
## 25.12.0 (2023-11-16)
### Bug Fix
diff --git a/packages/components/src/custom-select-control-v2/README.md b/packages/components/src/custom-select-control-v2/README.md
new file mode 100644
index 0000000000000..3cd9c3f8534e7
--- /dev/null
+++ b/packages/components/src/custom-select-control-v2/README.md
@@ -0,0 +1,73 @@
+
+This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes.
+
+
+### `CustomSelect`
+
+Used to render a customizable select control component.
+
+#### Props
+
+The component accepts the following props:
+
+##### `children`: `React.ReactNode`
+
+The child elements. This should be composed of CustomSelect.Item components.
+
+- Required: yes
+
+##### `defaultValue`: `string`
+
+An optional default value for the control. If left `undefined`, the first non-disabled item will be used.
+
+- Required: no
+
+##### `label`: `string`
+
+Label for the control.
+
+- Required: yes
+
+##### `onChange`: `( newValue: string ) => void`
+
+A function that receives the new value of the input.
+
+- Required: no
+
+##### `renderSelectedValue`: `( selectValue: string ) => React.ReactNode`
+
+Can be used to render select UI with custom styled values.
+
+- Required: no
+
+##### `size`: `'default' | 'large'`
+
+The size of the control.
+
+- Required: no
+
+##### `value`: `string`
+
+Can be used to externally control the value of the control.
+
+- Required: no
+
+### `CustomSelectItem`
+
+Used to render a select item.
+
+#### Props
+
+The component accepts the following props:
+
+##### `value`: `string`
+
+The value of the select item. This will be used as the children if children are left `undefined`.
+
+- Required: yes
+
+##### `children`: `React.ReactNode`
+
+The children to display for each select item. The `value` will be used if left `undefined`.
+
+- Required: no
diff --git a/packages/components/src/custom-select-control-v2/index.tsx b/packages/components/src/custom-select-control-v2/index.tsx
new file mode 100644
index 0000000000000..88231078fa8d5
--- /dev/null
+++ b/packages/components/src/custom-select-control-v2/index.tsx
@@ -0,0 +1,99 @@
+/**
+ * External dependencies
+ */
+// eslint-disable-next-line no-restricted-imports
+import * as Ariakit from '@ariakit/react';
+/**
+ * WordPress dependencies
+ */
+import { createContext, useContext } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import * as Styled from './styles';
+import type {
+ CustomSelectProps,
+ CustomSelectItemProps,
+ CustomSelectContext as CustomSelectContextType,
+} from './types';
+
+export const CustomSelectContext =
+ createContext< CustomSelectContextType >( undefined );
+
+function defaultRenderSelectedValue( value: CustomSelectProps[ 'value' ] ) {
+ const isValueEmpty = Array.isArray( value )
+ ? value.length === 0
+ : value === undefined || value === null;
+
+ if ( isValueEmpty ) {
+ return __( 'Select an item' );
+ }
+
+ if ( Array.isArray( value ) ) {
+ return value.length === 1
+ ? value[ 0 ]
+ : // translators: %s: number of items selected (it will always be 2 or more items)
+ sprintf( __( '%s items selected' ), value.length );
+ }
+
+ return value;
+}
+
+export function CustomSelect( props: CustomSelectProps ) {
+ const {
+ children,
+ defaultValue,
+ label,
+ onChange,
+ size = 'default',
+ value,
+ renderSelectedValue = defaultRenderSelectedValue,
+ } = props;
+
+ const store = Ariakit.useSelectStore( {
+ setValue: ( nextValue ) => onChange?.( nextValue ),
+ defaultValue,
+ value,
+ } );
+
+ const { value: currentValue } = store.useState();
+
+ return (
+ <>
+
+ { label }
+
+
+ { renderSelectedValue( currentValue ) }
+
+
+
+
+ { children }
+
+
+ >
+ );
+}
+
+export function CustomSelectItem( {
+ children,
+ ...props
+}: CustomSelectItemProps ) {
+ const customSelectContext = useContext( CustomSelectContext );
+ return (
+
+ { children ?? props.value }
+
+
+ );
+}
diff --git a/packages/components/src/custom-select-control-v2/stories/index.story.tsx b/packages/components/src/custom-select-control-v2/stories/index.story.tsx
new file mode 100644
index 0000000000000..2c7ae3507046b
--- /dev/null
+++ b/packages/components/src/custom-select-control-v2/stories/index.story.tsx
@@ -0,0 +1,149 @@
+/**
+ * External dependencies
+ */
+import type { Meta, StoryFn } from '@storybook/react';
+
+/**
+ * WordPress dependencies
+ */
+import { useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { CustomSelect, CustomSelectItem } from '..';
+
+const meta: Meta< typeof CustomSelect > = {
+ title: 'Components (Experimental)/CustomSelectControl v2',
+ component: CustomSelect,
+ subcomponents: {
+ // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
+ CustomSelectItem,
+ },
+ argTypes: {
+ children: { control: { type: null } },
+ renderSelectedValue: { control: { type: null } },
+ value: { control: { type: null } },
+ },
+ parameters: {
+ actions: { argTypesRegex: '^on.*' },
+ controls: { expanded: true },
+ docs: {
+ canvas: { sourceState: 'shown' },
+ source: { excludeDecorators: true },
+ },
+ },
+ decorators: [
+ ( Story ) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+
+const Template: StoryFn< typeof CustomSelect > = ( props ) => {
+ return ;
+};
+
+const ControlledTemplate: StoryFn< typeof CustomSelect > = ( props ) => {
+ const [ value, setValue ] = useState< string | string[] >();
+ return (
+ {
+ setValue( nextValue );
+ props.onChange?.( nextValue );
+ } }
+ value={ value }
+ />
+ );
+};
+
+export const Default = Template.bind( {} );
+Default.args = {
+ label: 'Label',
+ children: (
+ <>
+
+ Small
+
+
+ Something bigger
+
+ >
+ ),
+};
+
+/**
+ * Multiple selection can be enabled by using an array for the `value` and
+ * `defaultValue` props. The argument of the `onChange` function will also
+ * change accordingly.
+ */
+export const MultiSelect = Template.bind( {} );
+MultiSelect.args = {
+ defaultValue: [ 'lavender', 'tangerine' ],
+ label: 'Select Colors',
+ renderSelectedValue: ( currentValue: string | string[] ) => {
+ if ( ! Array.isArray( currentValue ) ) {
+ return currentValue;
+ }
+ if ( currentValue.length === 0 ) return 'No colors selected';
+ if ( currentValue.length === 1 ) return currentValue[ 0 ];
+ return `${ currentValue.length } colors selected`;
+ },
+ children: (
+ <>
+ { [
+ 'amber',
+ 'aquamarine',
+ 'flamingo pink',
+ 'lavender',
+ 'maroon',
+ 'tangerine',
+ ].map( ( item ) => (
+
+ { item }
+
+ ) ) }
+ >
+ ),
+};
+
+const renderControlledValue = ( gravatar: string | string[] ) => {
+ const avatar = `https://gravatar.com/avatar?d=${ gravatar }`;
+ return (
+
+
+
{ gravatar }
+
+ );
+};
+
+export const Controlled = ControlledTemplate.bind( {} );
+Controlled.args = {
+ label: 'Default Gravatars',
+ renderSelectedValue: renderControlledValue,
+ children: (
+ <>
+ { [ 'mystery-person', 'identicon', 'wavatar', 'retro' ].map(
+ ( option ) => (
+
+ { renderControlledValue( option ) }
+
+ )
+ ) }
+ >
+ ),
+};
diff --git a/packages/components/src/custom-select-control-v2/styles.ts b/packages/components/src/custom-select-control-v2/styles.ts
new file mode 100644
index 0000000000000..c04f6ac32e5ff
--- /dev/null
+++ b/packages/components/src/custom-select-control-v2/styles.ts
@@ -0,0 +1,76 @@
+/**
+ * External dependencies
+ */
+import styled from '@emotion/styled';
+// eslint-disable-next-line no-restricted-imports
+import * as Ariakit from '@ariakit/react';
+
+/**
+ * Internal dependencies
+ */
+import { COLORS } from '../utils';
+import { space } from '../utils/space';
+import type { CustomSelectProps } from './types';
+
+export const CustomSelectLabel = styled( Ariakit.SelectLabel )`
+ font-size: 11px;
+ font-weight: 500;
+ line-height: 1.4;
+ text-transform: uppercase;
+ margin-bottom: ${ space( 2 ) };
+`;
+
+const inputHeights = {
+ default: 40,
+ small: 24,
+};
+
+export const CustomSelectButton = styled( Ariakit.Select, {
+ // Do not forward `hasCustomRenderProp` to the underlying Ariakit.Select component
+ shouldForwardProp: ( prop ) => prop !== 'hasCustomRenderProp',
+} )( ( {
+ size,
+ hasCustomRenderProp,
+}: {
+ size: NonNullable< CustomSelectProps[ 'size' ] >;
+ hasCustomRenderProp: boolean;
+} ) => {
+ const isSmallSize = size === 'small' && ! hasCustomRenderProp;
+ const heightProperty = hasCustomRenderProp ? 'minHeight' : 'height';
+
+ return {
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ backgroundColor: COLORS.white,
+ border: `1px solid ${ COLORS.gray[ 600 ] }`,
+ borderRadius: space( 0.5 ),
+ cursor: 'pointer',
+ width: '100%',
+ [ heightProperty ]: `${ inputHeights[ size ] }px`,
+ padding: isSmallSize ? space( 2 ) : space( 4 ),
+ fontSize: isSmallSize ? '11px' : '13px',
+ '&[data-focus-visible]': {
+ outlineStyle: 'solid',
+ },
+ '&[aria-expanded="true"]': {
+ outlineStyle: `1.5px solid ${ COLORS.theme.accent }`,
+ },
+ };
+} );
+
+export const CustomSelectPopover = styled( Ariakit.SelectPopover )`
+ border-radius: ${ space( 0.5 ) };
+ background: ${ COLORS.white };
+ border: 1px solid ${ COLORS.gray[ 900 ] };
+`;
+
+export const CustomSelectItem = styled( Ariakit.SelectItem )`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: ${ space( 2 ) };
+ &[data-active-item] {
+ background-color: ${ COLORS.gray[ 300 ] };
+ }
+`;
diff --git a/packages/components/src/custom-select-control-v2/types.ts b/packages/components/src/custom-select-control-v2/types.ts
new file mode 100644
index 0000000000000..2aecc1d4746f5
--- /dev/null
+++ b/packages/components/src/custom-select-control-v2/types.ts
@@ -0,0 +1,63 @@
+/**
+ * External dependencies
+ */
+// eslint-disable-next-line no-restricted-imports
+import type * as Ariakit from '@ariakit/react';
+
+export type CustomSelectContext =
+ | {
+ /**
+ * The store object returned by Ariakit's `useSelectStore` hook.
+ */
+ store: Ariakit.SelectStore;
+ }
+ | undefined;
+
+export type CustomSelectProps = {
+ /**
+ * The child elements. This should be composed of CustomSelectItem components.
+ */
+ children: React.ReactNode;
+ /**
+ * An optional default value for the control. If left `undefined`, the first
+ * non-disabled item will be used.
+ */
+ defaultValue?: string | string[];
+ /**
+ * Label for the control.
+ */
+ label: string;
+ /**
+ * A function that receives the new value of the input.
+ */
+ onChange?: ( newValue: string | string[] ) => void;
+ /**
+ * Can be used to render select UI with custom styled values.
+ */
+ renderSelectedValue?: (
+ selectedValue: string | string[]
+ ) => React.ReactNode;
+ /**
+ * The size of the control.
+ *
+ * @default 'default'
+ */
+ size?: 'default' | 'small';
+ /**
+ * Can be used to externally control the value of the control.
+ */
+ value?: string | string[];
+};
+
+export type CustomSelectItemProps = {
+ /**
+ * The value of the select item. This will be used as the children if
+ * children are left `undefined`.
+ */
+ value: string;
+ /**
+ * The children to display for each select item. The `value` will be
+ * used if left `undefined`.
+ */
+ children?: React.ReactNode;
+};