, D> & {
+ /**
+ * The component used for the Root slot.
+ * Either a string to use a HTML element or a component.
+ * This is equivalent to `components.Root`. If both are provided, the `component` is used.
+ */
+ component?: D;
+};
+
+export interface ButtonUnstyledTypeMap {
+ props: P & ButtonUnstyledOwnProps;
+ defaultComponent: D;
+}
+
+export default ButtonUnstyledProps;
diff --git a/packages/material-ui-unstyled/src/ButtonUnstyled/UseButtonProps.ts b/packages/material-ui-unstyled/src/ButtonUnstyled/UseButtonProps.ts
new file mode 100644
index 00000000000000..a9c7457c472217
--- /dev/null
+++ b/packages/material-ui-unstyled/src/ButtonUnstyled/UseButtonProps.ts
@@ -0,0 +1,35 @@
+import * as React from 'react';
+
+export default interface UseButtonProps {
+ /**
+ * The component used for the Root slot.
+ * Either a string to use a HTML element or a component.
+ * This is equivalent to `components.Root`. If both are provided, the `component` is used.
+ * @default 'button'
+ */
+ component?: React.ElementType;
+ /**
+ * The components used for each slot inside the Button.
+ * Either a string to use a HTML element or a component.
+ * @default {}
+ */
+ components?: {
+ Root?: React.ElementType;
+ };
+ /**
+ * If `true`, the component is disabled.
+ * @default false
+ */
+ disabled?: boolean;
+ href?: string;
+ onClick?: React.MouseEventHandler;
+ onFocusVisible?: React.FocusEventHandler;
+ ref: React.Ref;
+ tabIndex?: NonNullable['tabIndex']>;
+ to?: string;
+ /**
+ * Type attribute applied when the `component` is `button`.
+ * @default 'button'
+ */
+ type?: React.ButtonHTMLAttributes['type'];
+}
diff --git a/packages/material-ui-unstyled/src/ButtonUnstyled/buttonUnstyledClasses.ts b/packages/material-ui-unstyled/src/ButtonUnstyled/buttonUnstyledClasses.ts
new file mode 100644
index 00000000000000..bc4ca040ecbde0
--- /dev/null
+++ b/packages/material-ui-unstyled/src/ButtonUnstyled/buttonUnstyledClasses.ts
@@ -0,0 +1,24 @@
+import generateUtilityClass from '../generateUtilityClass';
+import generateUtilityClasses from '../generateUtilityClasses';
+
+export interface ButtonUnstyledClasses {
+ root: string;
+ active: string;
+ disabled: string;
+ focusVisible: string;
+}
+
+export type ButtonUnstyledClassKey = keyof ButtonUnstyledClasses;
+
+export function getButtonUnstyledUtilityClass(slot: string): string {
+ return generateUtilityClass('ButtonUnstyled', slot);
+}
+
+const buttonUnstyledClasses: ButtonUnstyledClasses = generateUtilityClasses('ButtonUnstyled', [
+ 'root',
+ 'active',
+ 'disabled',
+ 'focusVisible',
+]);
+
+export default buttonUnstyledClasses;
diff --git a/packages/material-ui-unstyled/src/ButtonUnstyled/index.ts b/packages/material-ui-unstyled/src/ButtonUnstyled/index.ts
new file mode 100644
index 00000000000000..0d652d5917c80d
--- /dev/null
+++ b/packages/material-ui-unstyled/src/ButtonUnstyled/index.ts
@@ -0,0 +1,9 @@
+export { default } from './ButtonUnstyled';
+export {
+ default as buttonUnstyledClasses,
+ getButtonUnstyledUtilityClass,
+} from './buttonUnstyledClasses';
+export type { default as ButtonUnstyledProps } from './ButtonUnstyledProps';
+export * from './ButtonUnstyledProps';
+export { default as useButton } from './useButton';
+export type { default as UseButtonProps } from './UseButtonProps';
diff --git a/packages/material-ui-unstyled/src/ButtonUnstyled/useButton.test.tsx b/packages/material-ui-unstyled/src/ButtonUnstyled/useButton.test.tsx
new file mode 100644
index 00000000000000..e1bd3165d79ad5
--- /dev/null
+++ b/packages/material-ui-unstyled/src/ButtonUnstyled/useButton.test.tsx
@@ -0,0 +1,134 @@
+import * as React from 'react';
+import { useButton } from '@material-ui/unstyled/ButtonUnstyled';
+import { createClientRender, fireEvent } from 'test/utils';
+import { expect } from 'chai';
+import { spy } from 'sinon';
+
+describe('useButton', () => {
+ const render = createClientRender();
+
+ describe('state: active', () => {
+ describe('when using a button element', () => {
+ it('is set when triggered by mouse', () => {
+ const TestComponent = () => {
+ const buttonRef = React.useRef(null);
+ const { active, getRootProps } = useButton({ ref: buttonRef });
+
+ return ;
+ };
+
+ const { getByRole } = render();
+ const button = getByRole('button');
+ fireEvent.mouseDown(button);
+ expect(button).to.have.class('active');
+ fireEvent.mouseUp(button);
+ expect(button).not.to.have.class('active');
+ });
+
+ it('is set when triggered by keyboard', () => {
+ const TestComponent = () => {
+ const buttonRef = React.useRef(null);
+ const { active, getRootProps } = useButton({ ref: buttonRef });
+
+ return ;
+ };
+
+ const { getByRole } = render();
+ const button = getByRole('button');
+ button.focus();
+ fireEvent.keyDown(button, { key: ' ' });
+ expect(button).to.have.class('active');
+ fireEvent.keyUp(button, { key: ' ' });
+ expect(button).not.to.have.class('active');
+ });
+ });
+
+ describe('when using a span element', () => {
+ it('is set when triggered by mouse', () => {
+ const TestComponent = () => {
+ const buttonRef = React.useRef(null);
+ const { active, getRootProps } = useButton({ ref: buttonRef, component: 'span' });
+
+ return ;
+ };
+
+ const { getByRole } = render();
+ const button = getByRole('button');
+ fireEvent.mouseDown(button);
+ expect(button).to.have.class('active');
+ fireEvent.mouseUp(button);
+ expect(button).not.to.have.class('active');
+ });
+
+ it('is set when triggered by keyboard', () => {
+ const TestComponent = () => {
+ const buttonRef = React.useRef(null);
+ const { active, getRootProps } = useButton({ ref: buttonRef, component: 'span' });
+
+ return ;
+ };
+
+ const { getByRole } = render();
+ const button = getByRole('button');
+ button.focus();
+ fireEvent.keyDown(button, { key: ' ' });
+ expect(button).to.have.class('active');
+ fireEvent.keyUp(button, { key: ' ' });
+ expect(button).not.to.have.class('active');
+ });
+ });
+
+ describe('event handlers', () => {
+ interface WithClickHandler {
+ onClick: React.MouseEventHandler;
+ }
+
+ it('calls them when provided in props', () => {
+ const TestComponent = (props: WithClickHandler) => {
+ const ref = React.useRef(null);
+ const { getRootProps } = useButton({ ...props, ref });
+ return ;
+ };
+
+ const handleClick = spy();
+
+ const { getByRole } = render();
+ fireEvent.click(getByRole('button'));
+
+ expect(handleClick.callCount).to.equal(1);
+ });
+
+ it('calls them when provided in getRootProps()', () => {
+ const handleClick = spy();
+
+ const TestComponent = () => {
+ const ref = React.useRef(null);
+ const { getRootProps } = useButton({ ref });
+ return ;
+ };
+
+ const { getByRole } = render();
+ fireEvent.click(getByRole('button'));
+
+ expect(handleClick.callCount).to.equal(1);
+ });
+
+ it('calls the one provided in getRootProps() when both props and getRootProps have ones', () => {
+ const handleClickExternal = spy();
+ const handleClickInternal = spy();
+
+ const TestComponent = (props: WithClickHandler) => {
+ const ref = React.useRef(null);
+ const { getRootProps } = useButton({ ...props, ref });
+ return ;
+ };
+
+ const { getByRole } = render();
+ fireEvent.click(getByRole('button'));
+
+ expect(handleClickInternal.callCount).to.equal(1);
+ expect(handleClickExternal.callCount).to.equal(0);
+ });
+ });
+ });
+});
diff --git a/packages/material-ui-unstyled/src/ButtonUnstyled/useButton.ts b/packages/material-ui-unstyled/src/ButtonUnstyled/useButton.ts
new file mode 100644
index 00000000000000..802a1fe801e537
--- /dev/null
+++ b/packages/material-ui-unstyled/src/ButtonUnstyled/useButton.ts
@@ -0,0 +1,203 @@
+import * as React from 'react';
+import {
+ unstable_useEventCallback as useEventCallback,
+ unstable_useForkRef as useForkRef,
+ unstable_useIsFocusVisible as useIsFocusVisible,
+} from '@material-ui/utils';
+import UseButtonProps from './UseButtonProps';
+import extractEventHandlers from '../utils/extractEventHandlers';
+
+export default function useButton(props: UseButtonProps) {
+ const { component, components = {}, disabled = false, href, ref, tabIndex = 0, to, type } = props;
+
+ const buttonRef = React.useRef();
+
+ const [active, setActive] = React.useState(false);
+
+ const {
+ isFocusVisibleRef,
+ onFocus: handleFocusVisible,
+ onBlur: handleBlurVisible,
+ ref: focusVisibleRef,
+ } = useIsFocusVisible();
+
+ const [focusVisible, setFocusVisible] = React.useState(false);
+ if (disabled && focusVisible) {
+ setFocusVisible(false);
+ }
+
+ React.useEffect(() => {
+ isFocusVisibleRef.current = focusVisible;
+ }, [focusVisible, isFocusVisibleRef]);
+
+ const handleMouseLeave =
+ (otherHandlers: Record>) => (event: React.MouseEvent) => {
+ if (focusVisible) {
+ event.preventDefault();
+ }
+
+ otherHandlers.onMouseLeave?.(event);
+ };
+
+ const handleBlur =
+ (otherHandlers: Record>) => (event: React.FocusEvent) => {
+ handleBlurVisible(event);
+
+ if (isFocusVisibleRef.current === false) {
+ setFocusVisible(false);
+ }
+
+ otherHandlers.onBlur?.(event);
+ };
+
+ const handleFocus = useEventCallback(
+ (otherHandlers: Record>) =>
+ (event: React.FocusEvent) => {
+ // Fix for https://github.com/facebook/react/issues/7769
+ if (!buttonRef.current) {
+ buttonRef.current = event.currentTarget;
+ }
+
+ handleFocusVisible(event);
+ if (isFocusVisibleRef.current === true) {
+ setFocusVisible(true);
+ otherHandlers.onFocusVisible?.(event);
+ }
+
+ otherHandlers.onFocus?.(event);
+ },
+ );
+
+ const elementType = component ?? components.Root ?? 'button';
+
+ const isNonNativeButton = () => {
+ const button = buttonRef.current;
+ return (
+ elementType !== 'button' && !(button?.tagName === 'A' && (button as HTMLAnchorElement)?.href)
+ );
+ };
+
+ const handleMouseDown =
+ (otherHandlers: Record>) =>
+ (event: React.MouseEvent) => {
+ if (event.target === event.currentTarget && !disabled) {
+ setActive(true);
+ }
+
+ otherHandlers.onMouseDown?.(event);
+ };
+
+ const handleMouseUp =
+ (otherHandlers: Record>) =>
+ (event: React.MouseEvent) => {
+ if (event.target === event.currentTarget) {
+ setActive(false);
+ }
+
+ otherHandlers.onMouseUp?.(event);
+ };
+
+ const handleKeyDown = useEventCallback(
+ (otherHandlers: Record>) => (event: React.KeyboardEvent) => {
+ if (event.target === event.currentTarget && isNonNativeButton() && event.key === ' ') {
+ event.preventDefault();
+ }
+
+ if (event.target === event.currentTarget && event.key === ' ' && !disabled) {
+ setActive(true);
+ }
+
+ otherHandlers.onKeyDown?.(event);
+
+ // Keyboard accessibility for non interactive elements
+ if (
+ event.target === event.currentTarget &&
+ isNonNativeButton() &&
+ event.key === 'Enter' &&
+ !disabled
+ ) {
+ event.preventDefault();
+ otherHandlers.onClick?.(event);
+ }
+ },
+ );
+
+ const handleKeyUp = useEventCallback(
+ (otherHandlers: Record>) => (event: React.KeyboardEvent) => {
+ // calling preventDefault in keyUp on a