Skip to content

Menu Component#23

Merged
coldlink merged 3 commits intomainfrom
mm/menu
Mar 12, 2026
Merged

Menu Component#23
coldlink merged 3 commits intomainfrom
mm/menu

Conversation

@coldlink
Copy link
Member

@coldlink coldlink commented Mar 5, 2026

What does this change?

  • Adds Menu component and sub-components, based on the react aria Menu
  • Adds semantic shadow property to design system
  • Fixes issue with .gitignore any playwright tests

Menu

A dropdown menu component built on React Aria.
The Menu component combines a trigger element with a popover containing MenuItem, MenuSection, and MenuSeparator sub-components.
It supports single and multiple selection modes, icons, descriptions, keyboard navigation, and full accessibility out of the box.

When to use

  • To reveal a list of actions or options triggered by a button click
  • To group related actions using MenuSection with an optional header
  • To let users toggle settings with single or multiple selection modes

Peer dependencies

  • @emotion/react
  • react
  • react-dom
  • typescript
  • react-aria-components
  • @react-aria/focus

See the peerDependencies section of package.json for compatible versions.

See custom component build for usage without React/Emotion.

Example usage

import {
	Menu,
	MenuToggle,
	MenuSection,
	MenuItem,
	MenuSeparator,
} from '@guardian/stand/menu';
import { IconButton } from '@guardian/stand/icon-button';
import { Keyboard } from 'react-aria-components';

/* types, if required */
import type {
	MenuProps,
	MenuTheme,
	MenuItemProps,
	MenuItemTheme,
	MenuSectionProps,
	MenuSectionTheme,
	MenuSeparatorProps,
	MenuSeparatorTheme,
} from '@guardian/stand/menu';

<Menu>
	<MenuToggle>
		<IconButton symbol="settings" ariaLabel="Open menu" />
	</MenuToggle>

	<MenuSection name="File actions">
		<MenuItem
			icon="open_in_new"
			label="Open"
			description="Open in a new tab"
			aside={<Keyboard>⌘O</Keyboard>}
			onAction={() => console.log('open')}
		/>
		<MenuItem
			icon="edit"
			label="Rename"
			description="Rename the file"
			onAction={() => console.log('rename')}
		/>
		<MenuItem label="Delete" onAction={() => console.log('delete')} />
	</MenuSection>

	<MenuSeparator />

	<MenuSection
		selectionMode="multiple"
		defaultSelectedKeys={['files']}
		onSelectionChange={(keys) => console.log(keys)}
	>
		<MenuItem id="files" label="Show files" />
		<MenuItem id="folders" label="Show folders" />
	</MenuSection>

	<MenuSeparator />

	<MenuSection
		selectionMode="single"
		onSelectionChange={(keys) => console.log(keys)}
	>
		<MenuItem id="list" label="List view" />
		<MenuItem id="grid" label="Grid view" />
	</MenuSection>
</Menu>;

Components

The Menu component is composed of several sub-components. Only the components listed below are valid direct children of Menu and MenuSection.

Menu

Required

The root component. Wraps a MenuToggle and a popover containing MenuSection, MenuItem, and MenuSeparator components. Manages the open/close state of the popover and provides context for size and selection state.

Name Type Required Default Description
children React.ReactNode No - Must only contain MenuToggle, MenuItem, MenuSection, or MenuSeparator components.
size 'sm' | 'md' No 'md' Size variant. Passed down automatically to all child components.
popoverProps Omit<PopoverProps, 'children' | 'size'> No - Props for the underlying React Aria Popover, e.g. offset, placement.
menuTriggerProps Omit<MenuTriggerProps, 'children'> No - Props for the underlying React Aria MenuTrigger, e.g. isOpen to control open state.
theme Partial<MenuTheme> No - Custom theme overrides.
cssOverrides SerializedStyles No - Custom CSS styles applied to the <menu> element.
className string No - Additional class name(s).

Menu also accepts all props from the underlying React Aria Menu component.

MenuToggle

Required

Wraps the component used to toggle the menu open/closed state. Can be a form of Button/Link or a custom trigger. Must be a direct child of Menu and must contain exactly one interactive element.

Name Type Required Default Description
children React.ReactNode Yes - The trigger element, e.g. <IconButton>.

MenuSection

Groups MenuItem components into a labelled or unlabelled section. Supports single and multiple selection modes when a selectionMode is set so that each MenuItem within the section renders the appropriate selection indicator (checkbox or radio button) automatically.

Name Type Required Default Description
children React.ReactNode No - Must only contain MenuItem components.
name string No - Optional section header label rendered above the items.
size 'sm' | 'md' No 'md' Size variant. Automatically inherited from Menu, no need to pass manually.
selectionMode 'none' | 'single' | 'multiple' No 'none' Enables single or multiple item selection within the section.
defaultSelectedKeys Iterable<Key> No - Default selected keys (uncontrolled).
selectedKeys Iterable<Key> No - Selected keys (controlled). Use together with onSelectionChange.
onSelectionChange (keys: Selection) => void No - Called when the selection changes.
theme Partial<MenuSectionTheme> No - Custom theme overrides.
cssOverrides SerializedStyles No - Custom CSS styles applied to the section element.
className string No - Additional class name(s).

MenuSection also accepts all props from the underlying React Aria MenuSection component.

MenuItem

A single interactive item within a MenuSection. Renders a label with optional icon, description, and aside content. When the parent MenuSection has a selectionMode, the icon prop is ignored and a selection indicator is rendered instead.

Name Type Required Default Description
label React.ReactNode Yes - The main label. Can be a string or any React node for complex layouts.
id Key No - Unique key used for selection. Required when the parent MenuSection has a selectionMode.
description React.ReactNode No - Optional description rendered below the label in a smaller font.
aside React.ReactNode No - Optional content on the right side, e.g. a keyboard shortcut using <Keyboard> from react-aria-components.
icon string | SVGElement No - Icon for the left side. Accepts a Material Symbols name (string) or an SVG element. Hidden when selectionMode is set on the parent section.
size 'sm' | 'md' No 'md' Size variant. Automatically inherited from Menu, no need to pass manually.
textValue string No - Plain-text label used for typeahead keyboard search. Defaults to label when it is a string.
theme Partial<MenuItemTheme> No - Custom theme overrides.
cssOverrides SerializedStyles No - Custom CSS styles.
className string No - Additional class name(s).

MenuItem also accepts all props from the underlying React Aria MenuItem component.

MenuSeparator

A horizontal rule for visually separating groups at the top level of a Menu (between MenuSection components or standalone MenuItem components).

Name Type Required Default Description
theme Partial<MenuSeparatorTheme> No - Custom theme overrides.
cssOverrides SerializedStyles No - Custom CSS styles.
className string No - Additional class name(s).

Customisation

We recommend using the Menu component as provided, but individual sub-components can be customised using their theme or cssOverrides props as needed.

Custom theme

The theme prop allows you to override specific design tokens on any component, using the ComponentTheme type, or individual sub-component themes like MenuTheme, MenuItemTheme etc.

import type { ComponentMenu } from '@guardian/stand/menu';
import {
	Menu,
	MenuToggle,
	MenuSection,
	MenuItem,
	MenuSeparator,
} from '@guardian/stand/menu';
import { IconButton } from '@guardian/stand/icon-button';

const theme: DeepPartial<ComponentMenu> = {
	menu: {
		shared: {
			border: `${baseSizing['size-8-px']} solid ${baseColors.orange[900]}`,
			'background-color': baseColors.orange[50],
		},
	},
	menuItem: {
		shared: {
			':hover': {
				'background-color': baseColors.orange[100],
			},
			label: {
				color: baseColors.orange[900],
			},
			aside: {
				color: baseColors.orange[700],
			},
			icon: {
				color: baseColors.orange[900],
			},
			description: {
				color: baseColors.orange[700],
			},
		},
	},
	menuSection: {
		header: {
			shared: {
				'background-color': baseColors.orange[50],
				color: baseColors.orange[900],
			},
		},
	},
	menuSeparator: {
		shared: {
			'background-color': baseColors.orange[200],
		},
	},
	menuPopover: {
		shared: {
			shadow: `0 ${baseSizing['size-2-px']} ${baseSizing['size-16-px']} 0 rgb(255 165 0 / 0.3)`,
		},
	},
};

const Component = () => (
	<Menu theme={theme.menu} popoverProps={{ theme: theme.menuPopover }}>
		<MenuToggle>
			<IconButton symbol="settings" ariaLabel="Open menu" />
		</MenuToggle>
		<MenuSection theme={theme.menuSection}>
			<MenuItem label="Option" theme={theme.menuItem} />
		</MenuSection>
		<MenuSeparator theme={theme.menuSeparator} />
		<MenuSection theme={theme.menuSection}>
			<MenuItem label="Option" theme={theme.menuItem} />
		</MenuSection>
	</Menu>
);

CSS overrides

The cssOverrides prop allows you to pass custom CSS to any component:

import {
	Menu,
	MenuToggle,
	MenuSection,
	MenuItem,
	MenuSeparator,
} from '@guardian/stand/menu';
import { IconButton } from '@guardian/stand/icon-button';
import { css } from '@emotion/react';

const menuStyles = css`
	padding: ${baseSpacing['16-px']};
`;

const menuPopoverStyles = css`
	z-index: 1;
`;

const menuItemStyles = css`
	padding: ${baseSpacing['8-px']} ${baseSpacing['16-px']};
`;

const menuSeparatorStyles = css`
	filter: drop-shadow(0 1px 0 ${baseColors.blue[500]});
`;

const Component = () => (
	<Menu
		cssOverrides={menuStyles}
		popoverProps={{ cssOverrides: menuPopoverStyles }}
	>
		<MenuToggle>
			<IconButton symbol="settings" ariaLabel="Open menu" />
		</MenuToggle>
		<MenuSection>
			<MenuItem label="Option" cssOverrides={menuItemStyles} />
		</MenuSection>
		<MenuSeparator cssOverrides={menuSeparatorStyles} />
		<MenuSection>
			<MenuItem label="Option" cssOverrides={menuItemStyles} />
		</MenuSection>
	</Menu>
);

Custom Component Build

If you're not using React/Emotion, you can create a custom Menu component using the styles defined in the MenuTheme type.

You will however be responsible for any additional functionality on top of the styles, for example accessibility, focus management, interaction states etc.

@github-actions
Copy link

github-actions bot commented Mar 5, 2026

Dependency Compatibility Matrix

React Emotion TypeScript RAC Typecheck Unit E2E Build Overall
17 11.11.4 ~5.0 1.13.0 ok ok ok ok
17 11.11.4 ~5.0 1.16.0 ok ok ok ok
17 11.11.4 ~5.1 1.13.0 ok ok ok ok
17 11.11.4 ~5.1 1.16.0 ok ok ok ok
17 11.11.4 ~5.9 1.13.0 ok ok ok ok
17 11.11.4 ~5.9 1.16.0 ok ok ok ok
17 11.14.0 ~5.0 1.13.0 ok ok ok ok
17 11.14.0 ~5.0 1.16.0 ok ok ok ok
17 11.14.0 ~5.1 1.13.0 ok ok ok ok
17 11.14.0 ~5.1 1.16.0 ok ok ok ok
17 11.14.0 ~5.9 1.13.0 ok ok ok ok
17 11.14.0 ~5.9 1.16.0 ok ok ok ok
18 11.11.4 ~5.0 1.13.0 ok ok ok ok
18 11.11.4 ~5.0 1.16.0 ok ok ok ok
18 11.11.4 ~5.1 1.13.0 ok ok ok ok
18 11.11.4 ~5.1 1.16.0 ok ok ok ok
18 11.11.4 ~5.9 1.13.0 ok ok ok ok
18 11.11.4 ~5.9 1.16.0 ok ok ok ok
18 11.14.0 ~5.0 1.13.0 ok ok ok ok
18 11.14.0 ~5.0 1.16.0 ok ok ok ok
18 11.14.0 ~5.1 1.13.0 ok ok ok ok
18 11.14.0 ~5.1 1.16.0 ok ok ok ok
18 11.14.0 ~5.9 1.13.0 ok ok ok ok
18 11.14.0 ~5.9 1.16.0 ok ok ok ok
19 11.14.0 ~5.0 1.13.0 ok ok ok ok
19 11.14.0 ~5.0 1.16.0 ok ok ok ok
19 11.14.0 ~5.1 1.13.0 ok ok ok ok
19 11.14.0 ~5.1 1.16.0 ok ok ok ok
19 11.14.0 ~5.9 1.13.0 ok ok ok ok
19 11.14.0 ~5.9 1.16.0 ok ok ok ok

Columns show granular outcomes for each dependency set: ok = passed, fail = failed, skip = upstream failure prevented running later stages. Overall: ✅ all passed, ⚠️ only skips (no hard fails), ❌ at least one fail.
Last updated: 2026-03-12T17:06:31.099Z (commit ab1fe23)

@coldlink coldlink force-pushed the mm/menu branch 2 times, most recently from 850a1a3 to 270f57d Compare March 5, 2026 11:22
@coldlink coldlink added the feature Departmental tracking: work on a new feature label Mar 5, 2026
@coldlink coldlink force-pushed the mm/menu branch 3 times, most recently from e9e7b87 to 6f45c09 Compare March 9, 2026 15:24
@coldlink coldlink changed the title Mm/menu Menu Component Mar 9, 2026
@coldlink coldlink added the 🐥 Canaries Release a canary version of `@guardian/stand` label Mar 9, 2026
@github-actions
Copy link

github-actions bot commented Mar 9, 2026

Note

The following canaries were published to NPM:

🐥

@github-actions github-actions bot removed the 🐥 Canaries Release a canary version of `@guardian/stand` label Mar 9, 2026
@coldlink coldlink added the 🐥 Canaries Release a canary version of `@guardian/stand` label Mar 9, 2026
@github-actions
Copy link

github-actions bot commented Mar 9, 2026

Note

The following canaries were published to NPM:

🐥

@github-actions github-actions bot removed the 🐥 Canaries Release a canary version of `@guardian/stand` label Mar 9, 2026
@coldlink coldlink added the 🐥 Canaries Release a canary version of `@guardian/stand` label Mar 9, 2026
@github-actions
Copy link

github-actions bot commented Mar 9, 2026

Note

The following canaries were published to NPM:

🐥

@github-actions github-actions bot removed the 🐥 Canaries Release a canary version of `@guardian/stand` label Mar 9, 2026
@coldlink coldlink added the 🐥 Canaries Release a canary version of `@guardian/stand` label Mar 11, 2026
@github-actions
Copy link

Note

The following canaries were published to NPM:

🐥

@github-actions github-actions bot removed the 🐥 Canaries Release a canary version of `@guardian/stand` label Mar 11, 2026
@coldlink coldlink added the run_chromatic Run the chromatic/storybook action. label Mar 12, 2026
@coldlink coldlink marked this pull request as ready for review March 12, 2026 11:29
@coldlink coldlink requested a review from a team as a code owner March 12, 2026 11:29
@coldlink coldlink changed the title Menu Component Menu Component Mar 12, 2026
componentMenu.menuSection;
export const menuSectionHeaderStyles = (
theme: MenuSectionTheme,
{ size }: Required<Pick<MenuSectionProps, 'size'>>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, will copy this in other places

Copy link
Contributor

@andrewHEguardian andrewHEguardian left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great, have already tried it out in #25!

@coldlink coldlink changed the base branch from main to mm/partial-theme March 12, 2026 16:28
@coldlink coldlink force-pushed the mm/menu branch 2 times, most recently from 9b000d4 to 12ea837 Compare March 12, 2026 16:40
Base automatically changed from mm/partial-theme to main March 12, 2026 16:52
@coldlink coldlink merged commit 533e4d9 into main Mar 12, 2026
49 checks passed
@coldlink coldlink deleted the mm/menu branch March 12, 2026 17:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Departmental tracking: work on a new feature run_chromatic Run the chromatic/storybook action.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants