Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
d5020c6
Initial commit
ciampo Jan 15, 2026
ad737cc
Fix imports
ciampo Jan 15, 2026
78f1740
Add export
ciampo Jan 15, 2026
b18af26
Adjust --wpds-color-stroke-interactive-neutral-strong
ciampo Jan 15, 2026
87a35a3
CHANGELOG
ciampo Jan 15, 2026
5b44717
Fix stylelint errors
ciampo Jan 15, 2026
e86e697
Add @wordpress/compose to the UI package
ciampo Jan 15, 2026
c2c2771
Unify tests, fix lint errors
ciampo Jan 15, 2026
6e8038a
Update package.json
ciampo Jan 15, 2026
29fd581
remove cursor files (added by mistake)
ciampo Jan 15, 2026
207516a
Restore tooltip in stories
ciampo Jan 16, 2026
2fdc5da
Move best practices file
ciampo Jan 16, 2026
48f8ea7
Fix storybook mdx
ciampo Jan 16, 2026
ff894db
Revert "Adjust --wpds-color-stroke-interactive-neutral-strong"
ciampo Jan 29, 2026
5c9b9f2
Fix docs
ciampo Jan 29, 2026
4d961ed
Remove custom focusable prop in favour of using tabIndex directly
ciampo Jan 29, 2026
3531fb4
Use Icon component instead of cloning an SVG
ciampo Jan 29, 2026
e4e72b2
Fix renamed DS tokens
ciampo Jan 29, 2026
c84cc41
Use ComponentProps utility type
ciampo Jan 29, 2026
ebbde97
Support a second "State" argument in internal render prop type, like …
ciampo Jan 29, 2026
35100a6
Use underscore notation for Base UI imports
ciampo Jan 29, 2026
867d1a5
Fix unit tests
ciampo Jan 29, 2026
5a0b8b6
Simplify overflow measurement (remove unnecessary variables and checks)
ciampo Jan 29, 2026
29f0b25
remove stale CHANGELOG entry
ciampo Jan 29, 2026
823dff0
Remove wrong package references
ciampo Jan 29, 2026
4111ef7
Revert "Support a second "State" argument in internal render prop typ…
ciampo Jan 30, 2026
3423e50
Do not forward state in the render prop
ciampo Jan 30, 2026
44d47cd
Update snippet
ciampo Jan 30, 2026
d3c74cd
Fix changelog
ciampo Jan 30, 2026
bd5c63e
Refactor from `density="compact"` to `variant="minimal"`
ciampo Jan 30, 2026
ef18423
Move useState inside render function in docs code snippet
ciampo Jan 30, 2026
078ca16
Follow Base UI renaming convention
ciampo Jan 30, 2026
921d96d
Cleaner tabIndex fix in tests
ciampo Jan 30, 2026
71bb020
Fix RTL edge fade when scrolling horizontally
ciampo Jan 30, 2026
9966694
Simplify tabIndex calculation for tablist
ciampo Jan 30, 2026
bdafa50
Do not use BEM
ciampo Jan 30, 2026
90741f0
Simplify Storybook
ciampo Jan 30, 2026
5606714
Remove unnecessary tabIndex check
ciampo Jan 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion packages/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@

## Unreleased

### New Features

- Add `Tabs` primitive ([#74652](https://github.com/WordPress/gutenberg/pull/74652)).

## 0.6.0 (2026-01-29)

### New Features

- Add `Select` primitive ([#74661](https://github.com/WordPress/gutenberg/pull/74661)).

## 0.5.0 (2026-01-16)

### Breaking Changes
Expand All @@ -20,4 +28,3 @@
- Add `Button` component ([#74415](https://github.com/WordPress/gutenberg/pull/74415), [#74416](https://github.com/WordPress/gutenberg/pull/74416), [#74470](https://github.com/WordPress/gutenberg/pull/74470)).
- Add `InputLayout` primitive ([#74313](https://github.com/WordPress/gutenberg/pull/74313)).
- Add `Input` primitive ([#74615](https://github.com/WordPress/gutenberg/pull/74615)).
- Add `Select` primitive ([#74661](https://github.com/WordPress/gutenberg/pull/74661)).
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"dependencies": {
"@base-ui/react": "^1.0.0",
"@wordpress/a11y": "file:../a11y",
"@wordpress/compose": "file:../compose",
"@wordpress/element": "file:../element",
"@wordpress/i18n": "file:../i18n",
"@wordpress/icons": "file:../icons",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ export * from './form/primitives';
export * from './icon';
export * from './icon-button';
export * from './stack';
export * as Tabs from './tabs';
export * as Tooltip from './tooltip';
export * from './visually-hidden';
6 changes: 6 additions & 0 deletions packages/ui/src/tabs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { List } from './list';
import { Panel } from './panel';
import { Root } from './root';
import { Tab } from './tab';

export { Root, List, Panel, Tab };
130 changes: 130 additions & 0 deletions packages/ui/src/tabs/list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { forwardRef, useEffect, useState } from '@wordpress/element';
import clsx from 'clsx';
import { Tabs as _Tabs } from '@base-ui/react/tabs';
import { useMergeRefs } from '@wordpress/compose';
import styles from './style.module.css';
import type { TabListProps } from './types';

// Account for sub-pixel rounding errors.
const SCROLL_EPSILON = 1;

/**
* Groups the individual tab buttons.
*
* `Tabs` is a collection of React components that combine to render
* an [ARIA-compliant tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/).
*/
export const List = forwardRef< HTMLDivElement, TabListProps >(
function TabList(
{
children,
variant = 'default',
className,
activateOnFocus,
render,
...otherProps
},
forwardedRef
) {
const [ listEl, setListEl ] = useState< HTMLDivElement | null >( null );
const [ overflow, setOverflow ] = useState< {
first: boolean;
last: boolean;
isScrolling: boolean;
} >( {
first: false,
last: false,
isScrolling: false,
} );

// Check if list is overflowing when it scrolls or resizes.
useEffect( () => {
if ( ! listEl ) {
return;
}

const measureOverflow = () => {
const { scrollWidth, clientWidth, scrollLeft } = listEl;
const maxScroll = Math.max( scrollWidth - clientWidth, 0 );
const direction =
listEl.dir ||
( typeof window !== 'undefined'
? window.getComputedStyle( listEl ).direction
: 'ltr' );

const scrollFromStart =
direction === 'rtl' && scrollLeft < 0
? // In RTL layouts, scrollLeft is typically 0 at the visual "start"
// (right edge) and becomes negative toward the "end" (left edge).
// Normalize value for correct first/last detection logic.
-scrollLeft
: scrollLeft;

// Use SCROLL_EPSILON to handle subpixel rendering differences.
setOverflow( {
first: scrollFromStart > SCROLL_EPSILON,
last: scrollFromStart < maxScroll - SCROLL_EPSILON,
isScrolling: scrollWidth > clientWidth,
} );
};

const resizeObserver = new ResizeObserver( measureOverflow );
resizeObserver.observe( listEl );

Comment thread
ciampo marked this conversation as resolved.
let scrollTick = false;
const throttleMeasureOverflowOnScroll = () => {
if ( ! scrollTick ) {
requestAnimationFrame( () => {
measureOverflow();
scrollTick = false;
} );
scrollTick = true;
}
};
listEl.addEventListener(
'scroll',
throttleMeasureOverflowOnScroll,
{ passive: true }
);

// Initial check.
measureOverflow();

return () => {
listEl.removeEventListener(
'scroll',
throttleMeasureOverflowOnScroll
);
resizeObserver.disconnect();
};
}, [ listEl ] );

const mergedListRef = useMergeRefs( [
forwardedRef,
( el: HTMLDivElement | null ) => setListEl( el ),
] );

return (
<_Tabs.List
ref={ mergedListRef }
activateOnFocus={ activateOnFocus }
data-select-on-move={ activateOnFocus ? 'true' : 'false' }
className={ clsx(
styles.tablist,
overflow.first && styles[ 'is-overflowing-first' ],
overflow.last && styles[ 'is-overflowing-last' ],
styles[ `is-${ variant }-variant` ],
className
) }
{ ...otherProps }
tabIndex={
otherProps.tabIndex ??
( overflow.isScrolling ? -1 : undefined )
}
>
{ children }
<_Tabs.Indicator className={ styles.indicator } />
</_Tabs.List>
);
}
);
23 changes: 23 additions & 0 deletions packages/ui/src/tabs/panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { forwardRef } from '@wordpress/element';
import clsx from 'clsx';
import { Tabs as _Tabs } from '@base-ui/react/tabs';
import styles from './style.module.css';
import type { TabPanelProps } from './types';

/**
* A panel displayed when the corresponding tab is active.
*
* `Tabs` is a collection of React components that combine to render
* an [ARIA-compliant tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/).
*/
export const Panel = forwardRef< HTMLDivElement, TabPanelProps >(
function TabPanel( { className, ...otherProps }, forwardedRef ) {
return (
<_Tabs.Panel
ref={ forwardedRef }
className={ clsx( styles.tabpanel, className ) }
{ ...otherProps }
/>
);
}
);
15 changes: 15 additions & 0 deletions packages/ui/src/tabs/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { forwardRef } from '@wordpress/element';
import { Tabs as _Tabs } from '@base-ui/react/tabs';
import type { TabRootProps } from './types';

/**
* Groups the tabs and the corresponding panels.
*
* `Tabs` is a collection of React components that combine to render
* an [ARIA-compliant tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/).
*/
export const Root = forwardRef< HTMLDivElement, TabRootProps >(
function TabsRoot( { ...otherProps }, forwardedRef ) {
return <_Tabs.Root ref={ forwardedRef } { ...otherProps } />;
}
);
85 changes: 85 additions & 0 deletions packages/ui/src/tabs/stories/best-practices.mdx
Comment thread
ciampo marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Meta } from '@storybook/addon-docs/blocks';

<Meta title="Design System/Components/Tabs/Best Practices" />

# Tabs

## Usage

### Uncontrolled Mode

`Tabs` can be used in an uncontrolled mode, where the component manages its own state. In this mode, the `defaultValue` prop can be used to set the initially selected tab.

```jsx
import { Tabs } from '@wordpress/ui';

const MyUncontrolledTabs = () => (
<Tabs.Root
onValueChange={ ( tab ) => console.log( 'New selected tab: ', tab ) }

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.

Aside: I feel like we're missing some documentation on our approach to onValueChange vs. onChange props, since historical components used onChange for what we're now using onValueChange (i.e. value vs. raw event).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point. This feels like something to coordinate at the package level, especially related to our Form components (cc @mirka )

I guess it's up to us to decide how detailed we want our first-party documentation to be vs delagating to indivisual types docs vs linking to Base UI docs (if relevant)

defaultValue="tab2"
>
<Tabs.List>
<Tabs.Tab value="tab1">Tab 1</Tabs.Tab>
<Tabs.Tab value="tab2">Tab 2</Tabs.Tab>
<Tabs.Tab value="tab3">Tab 3</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="tab1">
<p>Selected tab: Tab 1</p>
</Tabs.Panel>
<Tabs.Panel value="tab2">
<p>Selected tab: Tab 2</p>
</Tabs.Panel>
<Tabs.Panel value="tab3">
<p>Selected tab: Tab 3</p>
</Tabs.Panel>
</Tabs.Root>
);
```

### Controlled Mode

Tabs can also be used in a controlled mode, where the parent component uses the `value` and `onValueChange` props to control tab selection. In this mode, the `defaultValue` prop will be ignored if it is provided. To indicate that no tabs are selected, pass `null` to the `value`.

```tsx
import { useState } from 'react';
import { Tabs } from '@wordpress/ui';

const MyControlledTabs = () => {
const [ selectedTabId, setSelectedTabId ] = useState<
string | undefined | null
>( null );

return (
<Tabs.Root
value={ selectedTabId }
onValueChange={ ( newSelectedTabId ) => {
setSelectedTabId( newSelectedTabId );
console.log( 'Selecting tab', newSelectedTabId );
} }
>
<Tabs.List>
<Tabs.Tab value="tab1">Tab 1</Tabs.Tab>
<Tabs.Tab value="tab2">Tab 2</Tabs.Tab>
<Tabs.Tab value="tab3">Tab 3</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="tab1">
<p>Selected tab: Tab 1</p>
</Tabs.Panel>
<Tabs.Panel value="tab2">
<p>Selected tab: Tab 2</p>
</Tabs.Panel>
<Tabs.Panel value="tab3">
<p>Selected tab: Tab 3</p>
</Tabs.Panel>
</Tabs.Root>
);
};
```

### Using `Tabs` with links

The semantics implemented by the `Tabs` component don't align well with the semantics needed by a list of links. Furthermore, end users usually expect every link to be tabbable, while `Tabs.List` is a [composite](https://w3c.github.io/aria/#composite) widget acting as a single tab stop.

For these reasons, even if the `Tabs` component is fully extensible, we don't recommend using `Tabs` with links, and we don't currently provide any related Storybook example.

We may provide a dedicated component for tabs-like links in the future based on the feedback received.
Loading
Loading