Skip to content

[ButtonBase] Add nativeButton prop#47989

Merged
mj12albert merged 21 commits intomui:masterfrom
mj12albert:refactor/button-base-hook
Mar 26, 2026
Merged

[ButtonBase] Add nativeButton prop#47989
mj12albert merged 21 commits intomui:masterfrom
mj12albert:refactor/button-base-hook

Conversation

@mj12albert
Copy link
Copy Markdown
Member

@mj12albert mj12albert commented Mar 16, 2026

Currently this JSX

const CustomButton = React.forwardRef<HTMLButtonElement>(function CustomButton(
  props,
  ref
) {
  return <button ref={ref} {...props} />;
});

<Button component={CustomButton} disabled>
  Custom
</Button>

Renders this incorrect HTML: https://stackblitz.com/edit/7wy4fcg4?file=src%2FDemo.tsx

<button tabindex="-1" role="button" aria-disabled="true">Custom</button>

Continuing from #47985, the component === 'button' heuristic doesn't work for when component is a React component; an explicit nativeButton: boolean prop is required upfront to render the correct HTML, with the constraint that the attributes have to be resolved before mount (so there is no DOM to check)

Who is affected and how:

A dev warning will be shown to users who are either:

  • replacing a native button with a component that resolves to a non-<button> element (e.g. component={MySpan}), or
  • replacing a non-native button (e.g. a div) with a component that resolves to a <button> element

e.g.

  • Button component={CustomButton} -> no warning if it resolves to <button>
  • ⚠️ Button component={StyledSpan} -> warn unless nativeButton={false} is specified
  • MenuItem component={CustomLi} -> no warning if it doesn't resolve to <button>
  • ⚠️ MenuItem component={CustomButton} -> warn unless nativeButton={true}

Affected components:

  • Button, Fab, IconButton, ListItemButton, MenuItem, StepButton, Tab, ToggleButton, AccordionSummary, BottomNavigationAction, CardActionArea, TableSortLabel inherit the nativeButton prop via ButtonBase
  • PaginationItem doesn't use ExtendButtonBaseTypeMap for some reason so the type/proptype needed to be manually added
  • Chip conditionally forwards nativeButton depending on whether it's a link (backward compatibility with "legacy" mode)

Preview

Added a docs section in the Button page: https://deploy-preview-47989--material-ui.netlify.app/material-ui/react-button/#rendering-non-native-buttons

@mj12albert mj12albert added the component: ButtonBase The React component. label Mar 16, 2026
@mui-bot
Copy link
Copy Markdown

mui-bot commented Mar 16, 2026

Netlify deploy preview

Bundle size report

Bundle Parsed size Gzip size
@mui/material 🔺+2.09KB(+0.42%) 🔺+757B(+0.53%)
@mui/lab 0B(0.00%) 0B(0.00%)
@mui/system 0B(0.00%) 0B(0.00%)
@mui/utils 0B(0.00%) 0B(0.00%)

Details of bundle changes

Generated by 🚫 dangerJS against e8632a1

@mj12albert mj12albert force-pushed the refactor/button-base-hook branch from 53c397d to 0396ac9 Compare March 16, 2026 09:02
@siriwatknp
Copy link
Copy Markdown
Member

I am good with the new prop but we should have a clear example (ideally real scenario) to update the docs too.

@zannager zannager added the scope: button Changes related to the button. label Mar 16, 2026
@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Mar 16, 2026
@mj12albert mj12albert force-pushed the refactor/button-base-hook branch from 51fc75b to b0f14e2 Compare March 16, 2026 14:47
@github-actions github-actions bot added PR: out-of-date The pull request has merge conflicts and can't be merged. and removed PR: out-of-date The pull request has merge conflicts and can't be merged. labels Mar 16, 2026
@mj12albert mj12albert force-pushed the refactor/button-base-hook branch from b0f14e2 to 2e14d7f Compare March 17, 2026 14:46
@github-actions github-actions bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Mar 17, 2026
@mj12albert mj12albert force-pushed the refactor/button-base-hook branch 4 times, most recently from f84fa2b to 6b83890 Compare March 17, 2026 16:27
@mj12albert mj12albert force-pushed the refactor/button-base-hook branch from 6b83890 to ae47aa4 Compare March 17, 2026 16:56
Comment on lines +259 to +262
if (disabled) {
event.preventDefault();
return;
}
Copy link
Copy Markdown
Member Author

@mj12albert mj12albert Mar 17, 2026

Choose a reason for hiding this comment

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

@siriwatknp Following up on the comment in #47985 (comment)

The additional click guard + return here would only affect anybody counting on programmatically focusing and clicking a disabled non-native ButtonBase-based component then also expecting the click handler to run, similar to #48003

@mj12albert mj12albert force-pushed the refactor/button-base-hook branch from 9dc6ca8 to 0562586 Compare March 17, 2026 21:24
@mj12albert mj12albert force-pushed the refactor/button-base-hook branch from 0562586 to f4b40dc Compare March 17, 2026 21:49
@mj12albert
Copy link
Copy Markdown
Member Author

I am good with the new prop but we should have a clear example (ideally real scenario) to update the docs too.

Added a brief section here ~

@mj12albert mj12albert added accessibility a11y type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature. and removed accessibility a11y labels Mar 18, 2026
@mj12albert mj12albert force-pushed the refactor/button-base-hook branch from be48251 to 0e3a9a6 Compare March 18, 2026 11:04

#### Replacing native button elements with non-interactive elements

The `nativeButton` prop is available on `<ButtonBase>` and all button-like components to ensure that they are rendered with the correct HTML attributes before hydration, for example during server-side rendering. This should be specified when when passing a React component to the `component` prop of a button-like component. This should be specified if the custom component either replaces the default rendered element:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

we can improve this phrasing, there's a double "when" and last 2 sentences start in the same way.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Updated in de25a79


## Rendering non-native buttons

The `nativeButton` prop can be used to allow buttons to remain keyboard accessible when passing a React component to the [`component`](/material-ui/guides/composition/#passing-other-react-components) prop that renders a non-interactive element like a `<div>`.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

For this, this information was not enough to understand fully what's going on. Maybe we need to have another example with the other case, when nativeButton is true and custom component is actually a button. Also, a an example when the prop is true and custom component returns not a button, we mention the warning.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Maybe we need to have another example with the other case, when nativeButton is true and custom component is actually a button.

I think this is pretty rare in practice (e.g. making a MenuItem render a <button> elem) and not worth documenting, the dev warning should be self explanatory enough ~

an example when the prop is true and custom component returns not a button

Same here, the dev warnings should be enough for the user to correct the issue without having to go to the docs; only button -> span/div is worth documenting here as it's the more common use-case among the various dev warning combinations

},
}),
[],
[buttonRef],
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

should refs be included in deps array?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It's just to satisfy eslint and has no runtime use, because the buttonRef is returned by the hook and isn't in the local scope anymore

*/
LinkComponent: PropTypes.elementType,
/**
* Whether the custom component should render a native `<button>` element when
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The custom component already renders whatever the user tells it to render. The nativeButton only allows non-buttons to retain button a11y. Or at least that's what I understood.

Copy link
Copy Markdown
Member Author

@mj12albert mj12albert Mar 18, 2026

Choose a reason for hiding this comment

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

Instead of should, how about Whether the custom component is expected to render…?

Copy link
Copy Markdown
Member

@siriwatknp siriwatknp left a comment

Choose a reason for hiding this comment

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

Review comment on type prop handling.

onTouchStart={handleTouchStart}
ref={handleRef}
tabIndex={disabled ? -1 : tabIndex}
type={type}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

type={type} is set here as a direct JSX prop, and then buttonProps (from getButtonProps) is spread below — which also includes a resolved type (e.g. type="button" when nativeButton && !hasFormAction, or undefined otherwise).

Since the spread comes after, buttonProps.type will override the direct type={type} prop. This means the direct prop here is effectively dead code for non-link paths.

However, it also means the raw type prop (possibly undefined) does NOT take effect — the hook's resolved value always wins. Is that intentional?

If so, consider removing the direct type={type} to avoid confusion. If the intent is for the user's type to win, it should come after the spread.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Removed in 0e5501b

For buttons the hook will set the type; for links, we add it to linkProps directly

@mj12albert mj12albert requested a review from siriwatknp March 20, 2026 15:19
@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Mar 24, 2026
# Conflicts:
#	packages/mui-material/src/Tab/Tab.test.js
@github-actions github-actions bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Mar 24, 2026
Copy link
Copy Markdown
Member

@silviuaavram silviuaavram left a comment

Choose a reason for hiding this comment

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

some comments, looks good! 🎉


if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useEffect(() => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

what's wrong with adding the check inside the hook and avoid the rules-of-hooks error?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We do this everywhere, e.g. https://github.com/mui/material-ui/blob/master/packages/mui-material/src/Tooltip/Tooltip.js#L365-L372

I believe it's for bundlers to tree shake the entire effect to minimize bundle size


const rootRef = React.useRef<HTMLElement | null>(null);
const focusableWhenDisabled = focusableWhenDisabledParam === true;
const { props: focusableWhenDisabledProps } = useFocusableWhenDisabled({
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why not just return focusableWhenDisabledProps instead of wrapping into another object?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Flattened, originally was just in case we needed to return some non-props stuff


const getButtonProps = React.useCallback(
<ExternalProps extends ButtonBaseExternalProps = ButtonBaseExternalProps>(
externalProps = EMPTY as ExternalProps,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In other cases I see we also default with {} instead of this EMPTY

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

A micro-optimization to avoid creating the empty object more than once, it could be worthwhile to do a sweep through the whole lib to fix this

@mj12albert mj12albert enabled auto-merge (squash) March 26, 2026 15:06
@mj12albert mj12albert merged commit da873f0 into mui:master Mar 26, 2026
23 checks passed
@mj12albert mj12albert deleted the refactor/button-base-hook branch March 26, 2026 15:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: ButtonBase The React component. scope: button Changes related to the button. type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants