diff --git a/.changeset/hip-moles-jam.md b/.changeset/hip-moles-jam.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/hip-moles-jam.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/headless/package.json b/packages/headless/package.json
index 1353767e78c..91e2b341f7c 100644
--- a/packages/headless/package.json
+++ b/packages/headless/package.json
@@ -9,6 +9,10 @@
"import": "./dist/primitives/accordion/index.js",
"types": "./dist/primitives/accordion/index.d.ts"
},
+ "./button": {
+ "import": "./dist/primitives/button/index.js",
+ "types": "./dist/primitives/button/index.d.ts"
+ },
"./tabs": {
"import": "./dist/primitives/tabs/index.js",
"types": "./dist/primitives/tabs/index.d.ts"
diff --git a/packages/headless/src/primitives/button/README.md b/packages/headless/src/primitives/button/README.md
new file mode 100644
index 00000000000..30c1a2bbf6e
--- /dev/null
+++ b/packages/headless/src/primitives/button/README.md
@@ -0,0 +1,67 @@
+# Button
+
+A button with the disabled behaviour a native `,
+ );
+
+ screen.getByRole('button', { name: 'Save' }).focus();
+ await user.keyboard('{Enter}');
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('drops out of the tab order when disabled', () => {
+ render(
+ }
+ disabled
+ >
+ Save
+ ,
+ );
+
+ const button = screen.getByRole('button', { name: 'Save' });
+ expect(button).toHaveAttribute('tabindex', '-1');
+ expect(button).toHaveAttribute('aria-disabled', 'true');
+ });
+
+ // An anchor is tabbable on its own, so this needs the explicit `-1` rather than
+ // the absence of the attribute.
+ it('drops a disabled link out of the tab order', async () => {
+ const user = userEvent.setup();
+ render(
+
+ }
+ disabled
+ >
+ Save
+
+
+
,
+ );
+
+ await user.tab();
+
+ expect(screen.getByRole('textbox', { name: 'After' })).toHaveFocus();
+ });
+
+ it('forwards a ref to the rendered element', () => {
+ const ref = React.createRef();
+ render(
+ }
+ >
+ Save
+ ,
+ );
+
+ expect(ref.current).toBe(screen.getByRole('button', { name: 'Save' }));
+ });
+
+ it('stays in the tab order when disabled and focusable', async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ render(
+ }
+ disabled
+ focusableWhenDisabled
+ onClick={onClick}
+ >
+ Save
+ ,
+ );
+
+ await user.tab();
+ const button = screen.getByRole('button', { name: 'Save' });
+ expect(button).toHaveFocus();
+
+ await user.keyboard('{Enter}');
+ expect(onClick).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('accessibility', () => {
+ it('has no axe violations when disabled and focusable', async () => {
+ const { container } = render(
+
+ Save
+ ,
+ );
+
+ const results = await axe(container);
+ expect(results.violations).toEqual([]);
+ });
+ });
+});
diff --git a/packages/headless/src/primitives/button/button.tsx b/packages/headless/src/primitives/button/button.tsx
new file mode 100644
index 00000000000..0713ad38feb
--- /dev/null
+++ b/packages/headless/src/primitives/button/button.tsx
@@ -0,0 +1,120 @@
+'use client';
+
+import React from 'react';
+
+import { type ComponentProps, mergeProps, useRender } from '../../utils';
+
+/** Props for {@link Button}. */
+export interface ButtonProps extends ComponentProps<'button'> {
+ /**
+ * Keeps the button in the tab order while `disabled`. A button that disables itself
+ * mid-interaction — while a form submits, say — otherwise drops focus to the body and
+ * the user loses their place on the page. The button is marked `aria-disabled` rather
+ * than `disabled`, and stays inert to clicks and keyboard activation.
+ * @default false
+ */
+ focusableWhenDisabled?: boolean;
+ /**
+ * Whether the rendered element is a native ``. Set to `false` alongside a
+ * `render` prop returning anything else, so the role, tab order, and Enter/Space
+ * activation a `` provides natively are applied instead.
+ * @default true
+ */
+ nativeButton?: boolean;
+}
+
+function isLink(element: HTMLElement): boolean {
+ return element.tagName === 'A' && element.hasAttribute('href');
+}
+
+function preventDefault(event: React.SyntheticEvent): void {
+ event.preventDefault();
+}
+
+/** Replaces the consumer's handler while disabled, so theirs never runs. */
+function noop(): void {}
+
+// Every key but `Tab`, so focus can still move off the button — the whole point of keeping
+// it focusable. Propagation is deliberately left alone: an enclosing dialog or menu still
+// sees the key.
+function preventDefaultUnlessTab(event: React.KeyboardEvent): void {
+ if (event.key !== 'Tab') {
+ event.preventDefault();
+ }
+}
+
+/**
+ * A button with the disabled behaviour a native `` cannot express: staying
+ * focusable while inert, and behaving like a button on elements that aren't one.
+ *
+ * @example
+ * // Focus survives the button disabling itself while the form submits
+ * Save
+ *
+ * @example
+ * // Button semantics on a link
+ * }>Settings
+ */
+// `HTMLElement` rather than `HTMLButtonElement`: `nativeButton={false}` renders an anchor or
+// a span, and the ref has to accept one.
+export const Button = React.forwardRef(function Button(props, ref) {
+ const { render, disabled = false, focusableWhenDisabled = false, nativeButton = true, ...otherProps } = props;
+
+ // The `disabled` attribute is what makes a native button inert, but it also takes the
+ // button out of the tab order — the one thing `focusableWhenDisabled` exists to avoid.
+ const nativelyDisabled = nativeButton && !focusableWhenDisabled;
+
+ const defaultProps: Record = nativeButton
+ ? { type: 'button', disabled: nativelyDisabled ? disabled : undefined }
+ : {
+ role: 'button',
+ // `-1` rather than dropping the attribute: an `` is tabbable on its own,
+ // so omitting it would leave a disabled link in the tab order.
+ tabIndex: disabled && !focusableWhenDisabled ? -1 : 0,
+ onKeyDown: (event: React.KeyboardEvent) => {
+ if (event.key === ' ') {
+ // Space scrolls the page on anything that is not a native button.
+ event.preventDefault();
+ } else if (event.key === 'Enter' && !isLink(event.currentTarget)) {
+ event.currentTarget.click();
+ }
+ },
+ onKeyUp: (event: React.KeyboardEvent) => {
+ if (event.key === ' ') {
+ event.currentTarget.click();
+ }
+ },
+ };
+
+ if (!nativelyDisabled) {
+ defaultProps['aria-disabled'] = disabled || undefined;
+ }
+
+ const merged = mergeProps<'button'>(defaultProps, otherProps);
+
+ if (disabled) {
+ // Without the `disabled` attribute the element still receives events, so they are
+ // suppressed here. These overwrite rather than chain: `mergeProps` runs the consumer's
+ // handler after ours, and a disabled button must not run it at all.
+ merged.onClick = preventDefault;
+ merged.onMouseDown = noop;
+ merged.onKeyUp = noop;
+ // Blocking the pointer press is what keeps focus on whatever currently holds it. It has
+ // to be `pointerdown` rather than `mousedown` — preventing that one does not stop focus.
+ merged.onPointerDown = preventDefault;
+ // Only a focusable disabled button needs its keys neutered; a natively disabled one
+ // never receives them.
+ merged.onKeyDown = focusableWhenDisabled ? preventDefaultUnlessTab : noop;
+ }
+
+ return useRender({
+ defaultTagName: 'button',
+ render,
+ ref,
+ state: { disabled },
+ stateAttributesMapping: {
+ disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null),
+ },
+ props: merged,
+ });
+});
diff --git a/packages/headless/src/primitives/button/index.ts b/packages/headless/src/primitives/button/index.ts
new file mode 100644
index 00000000000..e43c452992b
--- /dev/null
+++ b/packages/headless/src/primitives/button/index.ts
@@ -0,0 +1 @@
+export { Button, type ButtonProps } from './button';
diff --git a/packages/headless/vite.config.ts b/packages/headless/vite.config.ts
index 23382fe0e80..70a8fcb5adf 100644
--- a/packages/headless/vite.config.ts
+++ b/packages/headless/vite.config.ts
@@ -13,6 +13,7 @@ export default defineConfig({
lib: {
entry: {
'primitives/accordion/index': 'src/primitives/accordion/index.ts',
+ 'primitives/button/index': 'src/primitives/button/index.ts',
'primitives/tabs/index': 'src/primitives/tabs/index.ts',
'primitives/tooltip/index': 'src/primitives/tooltip/index.ts',
'primitives/popover/index': 'src/primitives/popover/index.ts',