diff --git a/docs/data/material/components/avatars/Spacing.js b/docs/data/material/components/avatars/Spacing.js
new file mode 100644
index 00000000000000..c0aebda531a9cf
--- /dev/null
+++ b/docs/data/material/components/avatars/Spacing.js
@@ -0,0 +1,26 @@
+import * as React from 'react';
+import Avatar from '@mui/material/Avatar';
+import AvatarGroup from '@mui/material/AvatarGroup';
+import Stack from '@mui/material/Stack';
+
+export default function Spacing() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/docs/data/material/components/avatars/Spacing.tsx b/docs/data/material/components/avatars/Spacing.tsx
new file mode 100644
index 00000000000000..c0aebda531a9cf
--- /dev/null
+++ b/docs/data/material/components/avatars/Spacing.tsx
@@ -0,0 +1,26 @@
+import * as React from 'react';
+import Avatar from '@mui/material/Avatar';
+import AvatarGroup from '@mui/material/AvatarGroup';
+import Stack from '@mui/material/Stack';
+
+export default function Spacing() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/docs/data/material/components/avatars/Spacing.tsx.preview b/docs/data/material/components/avatars/Spacing.tsx.preview
new file mode 100644
index 00000000000000..557dd187523778
--- /dev/null
+++ b/docs/data/material/components/avatars/Spacing.tsx.preview
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/data/material/components/avatars/avatars.md b/docs/data/material/components/avatars/avatars.md
index 08362acff64506..1901658a04164e 100644
--- a/docs/data/material/components/avatars/avatars.md
+++ b/docs/data/material/components/avatars/avatars.md
@@ -77,6 +77,12 @@ The `renderSurplus` prop is useful when you need to render the surplus based on
{{"demo": "CustomSurplusAvatars.js"}}
+### Spacing
+
+You can change the spacing between avatars using the `spacing` prop. You can use one of the presets (`"medium"`, the default, or `"small"`) or set a custom numeric value.
+
+{{"demo": "Spacing.js"}}
+
## With badge
{{"demo": "BadgeAvatars.js"}}
diff --git a/packages/mui-utils/src/getReactNodeRef/getReactNodeRef.ts b/packages/mui-utils/src/getReactNodeRef/getReactNodeRef.ts
new file mode 100644
index 00000000000000..2d094540339743
--- /dev/null
+++ b/packages/mui-utils/src/getReactNodeRef/getReactNodeRef.ts
@@ -0,0 +1,24 @@
+import * as React from 'react';
+
+/**
+ * Returns the ref of a React node handling differences between React 19 and older versions.
+ * It will return null if the node is not a valid React element.
+ *
+ * @param element React.ReactNode
+ * @returns React.Ref | null
+ *
+ * @deprecated Use getReactElementRef instead
+ */
+export default function getReactNodeRef(element: React.ReactNode): React.Ref | null {
+ if (!element || !React.isValidElement(element)) {
+ return null;
+ }
+
+ // 'ref' is passed as prop in React 19, whereas 'ref' is directly attached to children in older versions
+ return (element.props as any).propertyIsEnumerable('ref')
+ ? (element.props as any).ref
+ : // @ts-expect-error element.ref is not included in the ReactElement type
+ // We cannot check for it, but isValidElement is true at this point
+ // https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/70189
+ element.ref;
+}
diff --git a/packages/mui-utils/src/getReactNodeRef/index.ts b/packages/mui-utils/src/getReactNodeRef/index.ts
new file mode 100644
index 00000000000000..4b8dbacb937578
--- /dev/null
+++ b/packages/mui-utils/src/getReactNodeRef/index.ts
@@ -0,0 +1 @@
+export { default } from './getReactNodeRef';
diff --git a/packages/mui-utils/src/index.ts b/packages/mui-utils/src/index.ts
index e62a34808a4252..d1b651419059a1 100644
--- a/packages/mui-utils/src/index.ts
+++ b/packages/mui-utils/src/index.ts
@@ -27,6 +27,7 @@ export { default as unstable_useForkRef } from './useForkRef';
export { default as unstable_useLazyRef } from './useLazyRef';
export { default as unstable_useTimeout, Timeout as unstable_Timeout } from './useTimeout';
export { default as unstable_useOnMount } from './useOnMount';
+export { default as unstable_useIsFocusVisible } from './useIsFocusVisible';
export { default as unstable_isFocusVisible } from './isFocusVisible';
export { default as unstable_getScrollbarSize } from './getScrollbarSize';
export { default as usePreviousProps } from './usePreviousProps';
@@ -45,5 +46,6 @@ export { default as unstable_useSlotProps } from './useSlotProps';
export type { UseSlotPropsParameters, UseSlotPropsResult } from './useSlotProps';
export { default as unstable_resolveComponentProps } from './resolveComponentProps';
export { default as unstable_extractEventHandlers } from './extractEventHandlers';
+export { default as unstable_getReactNodeRef } from './getReactNodeRef';
export { default as unstable_getReactElementRef } from './getReactElementRef';
export * from './types';
diff --git a/packages/mui-utils/src/useIsFocusVisible/index.ts b/packages/mui-utils/src/useIsFocusVisible/index.ts
new file mode 100644
index 00000000000000..9f7a0b9ab5e7ad
--- /dev/null
+++ b/packages/mui-utils/src/useIsFocusVisible/index.ts
@@ -0,0 +1,2 @@
+export { default } from './useIsFocusVisible';
+export * from './useIsFocusVisible';
diff --git a/packages/mui-utils/src/useIsFocusVisible/useIsFocusVisible.test.js b/packages/mui-utils/src/useIsFocusVisible/useIsFocusVisible.test.js
new file mode 100644
index 00000000000000..d0c9350a1e0ad8
--- /dev/null
+++ b/packages/mui-utils/src/useIsFocusVisible/useIsFocusVisible.test.js
@@ -0,0 +1,126 @@
+import { expect } from 'chai';
+import * as React from 'react';
+import * as ReactDOMClient from 'react-dom/client';
+import {
+ act,
+ createRenderer,
+ focusVisible,
+ simulatePointerDevice,
+ programmaticFocusTriggersFocusVisible,
+} from '@mui/internal-test-utils';
+import useIsFocusVisible, { teardown as teardownFocusVisible } from './useIsFocusVisible';
+import useForkRef from '../useForkRef';
+
+const SimpleButton = React.forwardRef(function SimpleButton(props, ref) {
+ const {
+ isFocusVisibleRef,
+ onBlur: handleBlurVisible,
+ onFocus: handleFocusVisible,
+ ref: focusVisibleRef,
+ } = useIsFocusVisible();
+
+ const handleRef = useForkRef(focusVisibleRef, ref);
+
+ const [isFocusVisible, setIsFocusVisible] = React.useState(false);
+
+ const handleBlur = (event) => {
+ handleBlurVisible(event);
+ if (isFocusVisibleRef.current === false) {
+ setIsFocusVisible(false);
+ }
+ };
+
+ const handleFocus = (event) => {
+ handleFocusVisible(event);
+ if (isFocusVisibleRef.current === true) {
+ setIsFocusVisible(true);
+ }
+ };
+
+ return (
+
+ );
+});
+
+describe('useIsFocusVisible', () => {
+ const { render } = createRenderer();
+
+ before(() => {
+ // isolate test from previous component test that use the polyfill in the document scope
+ teardownFocusVisible(document);
+ });
+
+ describe('focus inside shadowRoot', () => {
+ before(function beforeHook() {
+ // Only run on HeadlessChrome which has native shadowRoot support.
+ // And jsdom which has limited support for shadowRoot (^12.0.0).
+ if (!/HeadlessChrome|jsdom/.test(window.navigator.userAgent)) {
+ this.skip();
+ }
+ });
+
+ let rootElement;
+ let reactRoot;
+
+ beforeEach(() => {
+ rootElement = document.createElement('div');
+ document.body.appendChild(rootElement);
+ rootElement.attachShadow({ mode: 'open' });
+ reactRoot = ReactDOMClient.createRoot(rootElement.shadowRoot);
+ });
+
+ afterEach(() => {
+ act(() => {
+ reactRoot.unmount();
+ });
+
+ teardownFocusVisible(rootElement.shadowRoot);
+ document.body.removeChild(rootElement);
+ });
+
+ it('should set focus state for shadowRoot children', () => {
+ const buttonRef = React.createRef();
+ render(
+
+ Hello
+ ,
+ {},
+ {
+ container: rootElement.shadowRoot,
+ },
+ );
+ simulatePointerDevice();
+
+ const { current: button } = buttonRef;
+ if (button.nodeName !== 'BUTTON') {
+ throw new Error('missing button');
+ }
+
+ expect(button.classList.contains('focus-visible')).to.equal(false);
+
+ act(() => {
+ button.focus();
+ });
+
+ if (programmaticFocusTriggersFocusVisible()) {
+ expect(button).to.have.class('focus-visible');
+ } else {
+ expect(button).not.to.have.class('focus-visible');
+ }
+
+ act(() => {
+ button.blur();
+ });
+ focusVisible(button);
+
+ expect(button.classList.contains('focus-visible')).to.equal(true);
+ });
+ });
+});
diff --git a/packages/mui-utils/src/useIsFocusVisible/useIsFocusVisible.ts b/packages/mui-utils/src/useIsFocusVisible/useIsFocusVisible.ts
new file mode 100644
index 00000000000000..74d444019e6522
--- /dev/null
+++ b/packages/mui-utils/src/useIsFocusVisible/useIsFocusVisible.ts
@@ -0,0 +1,175 @@
+'use client';
+// based on https://github.com/WICG/focus-visible/blob/v4.1.5/src/focus-visible.js
+import * as React from 'react';
+import { Timeout } from '../useTimeout/useTimeout';
+
+let hadKeyboardEvent = true;
+let hadFocusVisibleRecently = false;
+const hadFocusVisibleRecentlyTimeout = new Timeout();
+
+const inputTypesWhitelist: Record = {
+ text: true,
+ search: true,
+ url: true,
+ tel: true,
+ email: true,
+ password: true,
+ number: true,
+ date: true,
+ month: true,
+ week: true,
+ time: true,
+ datetime: true,
+ 'datetime-local': true,
+};
+
+/**
+ * Computes whether the given element should automatically trigger the
+ * `focus-visible` class being added, i.e. whether it should always match
+ * `:focus-visible` when focused.
+ * @param {Element} node
+ * @returns {boolean}
+ */
+function focusTriggersKeyboardModality(node: Element) {
+ const { type, tagName } = node as HTMLInputElement;
+
+ if (tagName === 'INPUT' && inputTypesWhitelist[type] && !(node as HTMLInputElement).readOnly) {
+ return true;
+ }
+
+ if (tagName === 'TEXTAREA' && !(node as HTMLInputElement).readOnly) {
+ return true;
+ }
+
+ if ((node as HTMLElement).isContentEditable) {
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * Keep track of our keyboard modality state with `hadKeyboardEvent`.
+ * If the most recent user interaction was via the keyboard;
+ * and the key press did not include a meta, alt/option, or control key;
+ * then the modality is keyboard. Otherwise, the modality is not keyboard.
+ * @param {KeyboardEvent} event
+ */
+function handleKeyDown(event: KeyboardEvent) {
+ if (event.metaKey || event.altKey || event.ctrlKey) {
+ return;
+ }
+ hadKeyboardEvent = true;
+}
+
+/**
+ * If at any point a user clicks with a pointing device, ensure that we change
+ * the modality away from keyboard.
+ * This avoids the situation where a user presses a key on an already focused
+ * element, and then clicks on a different element, focusing it with a
+ * pointing device, while we still think we're in keyboard modality.
+ */
+function handlePointerDown() {
+ hadKeyboardEvent = false;
+}
+
+function handleVisibilityChange(this: Document) {
+ if (this.visibilityState === 'hidden') {
+ // If the tab becomes active again, the browser will handle calling focus
+ // on the element (Safari actually calls it twice).
+ // If this tab change caused a blur on an element with focus-visible,
+ // re-apply the class when the user switches back to the tab.
+ if (hadFocusVisibleRecently) {
+ hadKeyboardEvent = true;
+ }
+ }
+}
+
+function prepare(doc: Document): void {
+ doc.addEventListener('keydown', handleKeyDown, true);
+ doc.addEventListener('mousedown', handlePointerDown, true);
+ doc.addEventListener('pointerdown', handlePointerDown, true);
+ doc.addEventListener('touchstart', handlePointerDown, true);
+ doc.addEventListener('visibilitychange', handleVisibilityChange, true);
+}
+
+export function teardown(doc: Document): void {
+ doc.removeEventListener('keydown', handleKeyDown, true);
+ doc.removeEventListener('mousedown', handlePointerDown, true);
+ doc.removeEventListener('pointerdown', handlePointerDown, true);
+ doc.removeEventListener('touchstart', handlePointerDown, true);
+ doc.removeEventListener('visibilitychange', handleVisibilityChange, true);
+}
+
+function isFocusVisible(event: React.FocusEvent): boolean {
+ const { target } = event;
+ try {
+ return target.matches(':focus-visible');
+ } catch (error) {
+ // Browsers not implementing :focus-visible will throw a SyntaxError.
+ // We use our own heuristic for those browsers.
+ // Rethrow might be better if it's not the expected error but do we really
+ // want to crash if focus-visible malfunctioned?
+ }
+
+ // No need for validFocusTarget check. The user does that by attaching it to
+ // focusable events only.
+ return hadKeyboardEvent || focusTriggersKeyboardModality(target);
+}
+
+export interface UseIsFocusVisibleResult {
+ isFocusVisibleRef: React.MutableRefObject;
+ onBlur: (event: React.FocusEvent) => void;
+ onFocus: (event: React.FocusEvent) => void;
+ ref: React.RefCallback;
+}
+
+export default function useIsFocusVisible(): UseIsFocusVisibleResult {
+ const ref = React.useCallback((node: HTMLElement) => {
+ if (node != null) {
+ prepare(node.ownerDocument);
+ }
+ }, []);
+
+ const isFocusVisibleRef = React.useRef(false);
+
+ /**
+ * Should be called if a blur event is fired
+ */
+ function handleBlurVisible() {
+ // checking against potential state variable does not suffice if we focus and blur synchronously.
+ // React wouldn't have time to trigger a re-render so `focusVisible` would be stale.
+ // Ideally we would adjust `isFocusVisible(event)` to look at `relatedTarget` for blur events.
+ // This doesn't work in IE11 due to https://github.com/facebook/react/issues/3751
+ // TODO: check again if React releases their internal changes to focus event handling (https://github.com/facebook/react/pull/19186).
+ if (isFocusVisibleRef.current) {
+ // To detect a tab/window switch, we look for a blur event followed
+ // rapidly by a visibility change.
+ // If we don't see a visibility change within 100ms, it's probably a
+ // regular focus change.
+ hadFocusVisibleRecently = true;
+ hadFocusVisibleRecentlyTimeout.start(100, () => {
+ hadFocusVisibleRecently = false;
+ });
+
+ isFocusVisibleRef.current = false;
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Should be called if a blur event is fired
+ */
+ function handleFocusVisible(event: React.FocusEvent) {
+ if (isFocusVisible(event)) {
+ isFocusVisibleRef.current = true;
+ return true;
+ }
+ return false;
+ }
+
+ return { isFocusVisibleRef, onFocus: handleFocusVisible, onBlur: handleBlurVisible, ref };
+}