diff --git a/docs/data/api/scroll-area-corner.json b/docs/data/api/scroll-area-corner.json
new file mode 100644
index 0000000000..a3c7a849e3
--- /dev/null
+++ b/docs/data/api/scroll-area-corner.json
@@ -0,0 +1,19 @@
+{
+ "props": {
+ "className": { "type": { "name": "union", "description": "func
| string" } },
+ "render": { "type": { "name": "union", "description": "element
| func" } }
+ },
+ "name": "ScrollAreaCorner",
+ "imports": [
+ "import { ScrollArea } from '@base_ui/react/ScrollArea';\nconst ScrollAreaCorner = ScrollArea.Corner;"
+ ],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "ScrollAreaCorner",
+ "forwardsRefTo": "HTMLDivElement",
+ "filename": "/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.tsx",
+ "inheritance": null,
+ "demos": "
",
+ "cssComponent": false
+}
diff --git a/docs/data/api/scroll-area-root.json b/docs/data/api/scroll-area-root.json
new file mode 100644
index 0000000000..5882aab749
--- /dev/null
+++ b/docs/data/api/scroll-area-root.json
@@ -0,0 +1,23 @@
+{
+ "props": {
+ "className": { "type": { "name": "union", "description": "func
| string" } },
+ "gutter": {
+ "type": { "name": "union", "description": "number
| string" },
+ "default": "0"
+ },
+ "render": { "type": { "name": "union", "description": "element
| func" } }
+ },
+ "name": "ScrollAreaRoot",
+ "imports": [
+ "import { ScrollArea } from '@base_ui/react/ScrollArea';\nconst ScrollAreaRoot = ScrollArea.Root;"
+ ],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "ScrollAreaRoot",
+ "forwardsRefTo": "HTMLDivElement",
+ "filename": "/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/data/api/scroll-area-scrollbar.json b/docs/data/api/scroll-area-scrollbar.json
new file mode 100644
index 0000000000..9664aee8b6
--- /dev/null
+++ b/docs/data/api/scroll-area-scrollbar.json
@@ -0,0 +1,24 @@
+{
+ "props": {
+ "className": { "type": { "name": "union", "description": "func
| string" } },
+ "keepMounted": { "type": { "name": "bool" }, "default": "false" },
+ "orientation": {
+ "type": { "name": "enum", "description": "'horizontal'
| 'vertical'" },
+ "default": "'vertical'"
+ },
+ "render": { "type": { "name": "union", "description": "element
| func" } }
+ },
+ "name": "ScrollAreaScrollbar",
+ "imports": [
+ "import { ScrollArea } from '@base_ui/react/ScrollArea';\nconst ScrollAreaScrollbar = ScrollArea.Scrollbar;"
+ ],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "ScrollAreaScrollbar",
+ "forwardsRefTo": "HTMLDivElement",
+ "filename": "/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/data/api/scroll-area-thumb.json b/docs/data/api/scroll-area-thumb.json
new file mode 100644
index 0000000000..0e401ae75c
--- /dev/null
+++ b/docs/data/api/scroll-area-thumb.json
@@ -0,0 +1,19 @@
+{
+ "props": {
+ "className": { "type": { "name": "union", "description": "func
| string" } },
+ "render": { "type": { "name": "union", "description": "element
| func" } }
+ },
+ "name": "ScrollAreaThumb",
+ "imports": [
+ "import { ScrollArea } from '@base_ui/react/ScrollArea';\nconst ScrollAreaThumb = ScrollArea.Thumb;"
+ ],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "ScrollAreaThumb",
+ "forwardsRefTo": "HTMLDivElement",
+ "filename": "/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/data/api/scroll-area-viewport.json b/docs/data/api/scroll-area-viewport.json
new file mode 100644
index 0000000000..614419f1a8
--- /dev/null
+++ b/docs/data/api/scroll-area-viewport.json
@@ -0,0 +1,19 @@
+{
+ "props": {
+ "className": { "type": { "name": "union", "description": "func
| string" } },
+ "render": { "type": { "name": "union", "description": "element
| func" } }
+ },
+ "name": "ScrollAreaViewport",
+ "imports": [
+ "import { ScrollArea } from '@base_ui/react/ScrollArea';\nconst ScrollAreaViewport = ScrollArea.Viewport;"
+ ],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "ScrollAreaViewport",
+ "forwardsRefTo": "HTMLDivElement",
+ "filename": "/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/data/components/scroll-area/ScrollAreaInset.js b/docs/data/components/scroll-area/ScrollAreaInset.js
new file mode 100644
index 0000000000..4325f067ee
--- /dev/null
+++ b/docs/data/components/scroll-area/ScrollAreaInset.js
@@ -0,0 +1,113 @@
+'use client';
+import * as React from 'react';
+import { ScrollArea } from '@base_ui/react/ScrollArea';
+import { styled } from '@mui/system';
+
+const data = [
+ 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
+ '7c9e6679-7425-40de-944b-e07fc1f90ae7',
+ '550e8400-e29b-41d4-a716-446655440000',
+ '9b2b38e2-4c7b-4e53-a228-c89c535c5072',
+ '3fa85f64-5717-4562-b3fc-2c963f66afa6',
+ '4dfbdfc4-2d0e-4e6c-8bd6-7c8d765f0a1c',
+ 'aa9e5d30-cf2a-4234-bc9b-6a5d965c6a00',
+ '16fd2706-8baf-433b-82eb-8c7fada847da',
+ '66ed7a57-e4b7-4b82-8b1e-2a8942f8ec6e',
+ 'f9e87c8f-7b4f-4c7e-bb72-ebe8e2277c5e',
+];
+
+const scrollbarSize = 10;
+
+export default function ScrollAreaInset() {
+ return (
+
+
+
+
User IDs
+
+ {data.map((value) => (
+ -
+ {value}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const ScrollAreaRoot = styled(ScrollArea.Root)`
+ width: 250px;
+ height: 250px;
+ border-radius: 2px;
+ background: #f5f5f5;
+
+ --scrollbar-size: ${scrollbarSize}px;
+`;
+
+const ScrollAreaViewport = styled(ScrollArea.Viewport)`
+ width: 100%;
+ height: 100%;
+ border-radius: 2px;
+
+ &:focus-visible {
+ outline: 2px solid rgb(0 0 0 / 0.5);
+ }
+`;
+
+const ScrollAreaScrollbar = styled(ScrollArea.Scrollbar)`
+ background: rgb(220 220 220);
+ box-sizing: border-box;
+ display: flex;
+
+ &[data-orientation='vertical'] {
+ width: var(--scrollbar-size);
+ }
+
+ &[data-orientation='horizontal'] {
+ flex-direction: column;
+ height: var(--scrollbar-size);
+ }
+`;
+
+const ScrollAreaThumb = styled(ScrollArea.Thumb)`
+ background: rgb(180 180 180);
+ flex: 1;
+
+ &:hover {
+ background: rgb(150 150 150);
+ }
+
+ &::before {
+ content: '';
+ display: block;
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ min-width: 22px;
+ min-height: 22px;
+ }
+`;
+
+const ScrollAreaCorner = styled(ScrollArea.Corner)`
+ background: rgb(220 220 220);
+`;
diff --git a/docs/data/components/scroll-area/ScrollAreaInset.tsx b/docs/data/components/scroll-area/ScrollAreaInset.tsx
new file mode 100644
index 0000000000..4325f067ee
--- /dev/null
+++ b/docs/data/components/scroll-area/ScrollAreaInset.tsx
@@ -0,0 +1,113 @@
+'use client';
+import * as React from 'react';
+import { ScrollArea } from '@base_ui/react/ScrollArea';
+import { styled } from '@mui/system';
+
+const data = [
+ 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
+ '7c9e6679-7425-40de-944b-e07fc1f90ae7',
+ '550e8400-e29b-41d4-a716-446655440000',
+ '9b2b38e2-4c7b-4e53-a228-c89c535c5072',
+ '3fa85f64-5717-4562-b3fc-2c963f66afa6',
+ '4dfbdfc4-2d0e-4e6c-8bd6-7c8d765f0a1c',
+ 'aa9e5d30-cf2a-4234-bc9b-6a5d965c6a00',
+ '16fd2706-8baf-433b-82eb-8c7fada847da',
+ '66ed7a57-e4b7-4b82-8b1e-2a8942f8ec6e',
+ 'f9e87c8f-7b4f-4c7e-bb72-ebe8e2277c5e',
+];
+
+const scrollbarSize = 10;
+
+export default function ScrollAreaInset() {
+ return (
+
+
+
+
User IDs
+
+ {data.map((value) => (
+ -
+ {value}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const ScrollAreaRoot = styled(ScrollArea.Root)`
+ width: 250px;
+ height: 250px;
+ border-radius: 2px;
+ background: #f5f5f5;
+
+ --scrollbar-size: ${scrollbarSize}px;
+`;
+
+const ScrollAreaViewport = styled(ScrollArea.Viewport)`
+ width: 100%;
+ height: 100%;
+ border-radius: 2px;
+
+ &:focus-visible {
+ outline: 2px solid rgb(0 0 0 / 0.5);
+ }
+`;
+
+const ScrollAreaScrollbar = styled(ScrollArea.Scrollbar)`
+ background: rgb(220 220 220);
+ box-sizing: border-box;
+ display: flex;
+
+ &[data-orientation='vertical'] {
+ width: var(--scrollbar-size);
+ }
+
+ &[data-orientation='horizontal'] {
+ flex-direction: column;
+ height: var(--scrollbar-size);
+ }
+`;
+
+const ScrollAreaThumb = styled(ScrollArea.Thumb)`
+ background: rgb(180 180 180);
+ flex: 1;
+
+ &:hover {
+ background: rgb(150 150 150);
+ }
+
+ &::before {
+ content: '';
+ display: block;
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ min-width: 22px;
+ min-height: 22px;
+ }
+`;
+
+const ScrollAreaCorner = styled(ScrollArea.Corner)`
+ background: rgb(220 220 220);
+`;
diff --git a/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js
new file mode 100644
index 0000000000..958cd97fd3
--- /dev/null
+++ b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.js
@@ -0,0 +1,123 @@
+'use client';
+import * as React from 'react';
+import { ScrollArea } from '@base_ui/react/ScrollArea';
+import { styled } from '@mui/system';
+
+const data = [
+ 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
+ '7c9e6679-7425-40de-944b-e07fc1f90ae7',
+ '550e8400-e29b-41d4-a716-446655440000',
+ '9b2b38e2-4c7b-4e53-a228-c89c535c5072',
+ '3fa85f64-5717-4562-b3fc-2c963f66afa6',
+ '4dfbdfc4-2d0e-4e6c-8bd6-7c8d765f0a1c',
+ 'aa9e5d30-cf2a-4234-bc9b-6a5d965c6a00',
+ '16fd2706-8baf-433b-82eb-8c7fada847da',
+ '66ed7a57-e4b7-4b82-8b1e-2a8942f8ec6e',
+ 'f9e87c8f-7b4f-4c7e-bb72-ebe8e2277c5e',
+];
+
+export default function ScrollAreaIntroduction() {
+ return (
+
+
+
+
User IDs
+
+ {data.map((value) => (
+ -
+ {value}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const ScrollAreaRoot = styled(ScrollArea.Root)`
+ width: 250px;
+ height: 250px;
+ border-radius: 6px;
+ background: #f5f5f5;
+`;
+
+const ScrollAreaViewport = styled(ScrollArea.Viewport)`
+ width: 100%;
+ height: 100%;
+ border-radius: 6px;
+
+ &:focus-visible {
+ outline: 2px solid rgb(0 0 0 / 0.5);
+ }
+`;
+
+const ScrollAreaScrollbar = styled(ScrollArea.Scrollbar)`
+ padding: 2px;
+ visibility: hidden;
+ background: transparent;
+ box-sizing: border-box;
+ transition:
+ opacity 0.2s,
+ background 0.2s,
+ visibility 0.2s;
+ opacity: 0;
+ display: flex;
+
+ &:hover {
+ background: rgb(0 0 0 / 0.1);
+ }
+
+ &[data-orientation='vertical'] {
+ width: 10px;
+ }
+
+ &[data-orientation='horizontal'] {
+ flex-direction: column;
+ height: 10px;
+ }
+
+ &[data-hovering],
+ &[data-scrolling],
+ &:hover {
+ visibility: visible;
+ opacity: 1;
+ }
+
+ &[data-scrolling]:not(:hover) {
+ transition: none;
+ }
+`;
+
+const ScrollAreaThumb = styled(ScrollArea.Thumb)`
+ background: rgb(0 0 0 / 0.5);
+ border-radius: 20px;
+ flex: 1;
+
+ &::before {
+ content: '';
+ display: block;
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ min-width: 22px;
+ min-height: 22px;
+ }
+`;
diff --git a/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.tsx b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.tsx
new file mode 100644
index 0000000000..958cd97fd3
--- /dev/null
+++ b/docs/data/components/scroll-area/ScrollAreaIntroduction/system/index.tsx
@@ -0,0 +1,123 @@
+'use client';
+import * as React from 'react';
+import { ScrollArea } from '@base_ui/react/ScrollArea';
+import { styled } from '@mui/system';
+
+const data = [
+ 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
+ '7c9e6679-7425-40de-944b-e07fc1f90ae7',
+ '550e8400-e29b-41d4-a716-446655440000',
+ '9b2b38e2-4c7b-4e53-a228-c89c535c5072',
+ '3fa85f64-5717-4562-b3fc-2c963f66afa6',
+ '4dfbdfc4-2d0e-4e6c-8bd6-7c8d765f0a1c',
+ 'aa9e5d30-cf2a-4234-bc9b-6a5d965c6a00',
+ '16fd2706-8baf-433b-82eb-8c7fada847da',
+ '66ed7a57-e4b7-4b82-8b1e-2a8942f8ec6e',
+ 'f9e87c8f-7b4f-4c7e-bb72-ebe8e2277c5e',
+];
+
+export default function ScrollAreaIntroduction() {
+ return (
+
+
+
+
User IDs
+
+ {data.map((value) => (
+ -
+ {value}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const ScrollAreaRoot = styled(ScrollArea.Root)`
+ width: 250px;
+ height: 250px;
+ border-radius: 6px;
+ background: #f5f5f5;
+`;
+
+const ScrollAreaViewport = styled(ScrollArea.Viewport)`
+ width: 100%;
+ height: 100%;
+ border-radius: 6px;
+
+ &:focus-visible {
+ outline: 2px solid rgb(0 0 0 / 0.5);
+ }
+`;
+
+const ScrollAreaScrollbar = styled(ScrollArea.Scrollbar)`
+ padding: 2px;
+ visibility: hidden;
+ background: transparent;
+ box-sizing: border-box;
+ transition:
+ opacity 0.2s,
+ background 0.2s,
+ visibility 0.2s;
+ opacity: 0;
+ display: flex;
+
+ &:hover {
+ background: rgb(0 0 0 / 0.1);
+ }
+
+ &[data-orientation='vertical'] {
+ width: 10px;
+ }
+
+ &[data-orientation='horizontal'] {
+ flex-direction: column;
+ height: 10px;
+ }
+
+ &[data-hovering],
+ &[data-scrolling],
+ &:hover {
+ visibility: visible;
+ opacity: 1;
+ }
+
+ &[data-scrolling]:not(:hover) {
+ transition: none;
+ }
+`;
+
+const ScrollAreaThumb = styled(ScrollArea.Thumb)`
+ background: rgb(0 0 0 / 0.5);
+ border-radius: 20px;
+ flex: 1;
+
+ &::before {
+ content: '';
+ display: block;
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ min-width: 22px;
+ min-height: 22px;
+ }
+`;
diff --git a/docs/data/components/scroll-area/scroll-area.mdx b/docs/data/components/scroll-area/scroll-area.mdx
new file mode 100644
index 0000000000..a3e533396b
--- /dev/null
+++ b/docs/data/components/scroll-area/scroll-area.mdx
@@ -0,0 +1,67 @@
+---
+productId: base-ui
+title: React Scroll Area component
+description: Scroll Area creates a scrollable region with custom scrollbars.
+components: ScrollAreaRoot, ScrollAreaViewport, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaCorner
+githubLabel: 'component: scrollarea'
+---
+
+# Scroll Area
+
+
+
+
+
+## Introduction
+
+
+
+## Installation
+
+
+
+## Anatomy
+
+```jsx
+
+
+
+
+
+
+
+
+
+
+```
+
+## Styling
+
+The scrollbar elements can be shown conditionally based on the user's interaction with the Scroll Area. The `[data-scrolling]` and `[data-hovering]` attributes are added to the scrollbar element while the user is scrolling or hovering over the Scroll Area.
+
+```css
+.ScrollAreaScrollbar[data-scrolling],
+.ScrollAreaScrollbar[data-hovering] {
+ opacity: 1;
+}
+```
+
+## Inset scrollbars
+
+By specifying a `gutter` prop, you can create inset scrollbars that make space for the scrollbar, preventing them from overlapping content. The value should match the size of the scrollbar width/height.
+
+```jsx
+
+```
+
+
+
+## Corner
+
+The vertical and horizontal scrollbar elements can prevent overlapping each other by rendering a `Corner`:
+
+```jsx
+
+
+
+```
diff --git a/docs/data/pages.ts b/docs/data/pages.ts
index c7064d8bda..282f4fac00 100644
--- a/docs/data/pages.ts
+++ b/docs/data/pages.ts
@@ -37,6 +37,7 @@ const pages: readonly RouteMetadata[] = [
{ pathname: '/components/react-preview-card', title: 'Preview Card' },
{ pathname: '/components/react-progress', title: 'Progress' },
{ pathname: '/components/react-radio-group', title: 'Radio Group' },
+ { pathname: '/components/react-scroll-area', title: 'Scroll Area' },
{ pathname: '/components/react-separator', title: 'Separator' },
{ pathname: '/components/react-slider', title: 'Slider' },
{ pathname: '/components/react-switch', title: 'Switch' },
diff --git a/docs/data/translations/api-docs/scroll-area-corner/scroll-area-corner.json b/docs/data/translations/api-docs/scroll-area-corner/scroll-area-corner.json
new file mode 100644
index 0000000000..4bc12cf1e0
--- /dev/null
+++ b/docs/data/translations/api-docs/scroll-area-corner/scroll-area-corner.json
@@ -0,0 +1,10 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "render": { "description": "A function to customize rendering of the component." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/data/translations/api-docs/scroll-area-root/scroll-area-root.json b/docs/data/translations/api-docs/scroll-area-root/scroll-area-root.json
new file mode 100644
index 0000000000..b163a6e188
--- /dev/null
+++ b/docs/data/translations/api-docs/scroll-area-root/scroll-area-root.json
@@ -0,0 +1,11 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "gutter": { "description": "Determines the space to account for inset scrollbars." },
+ "render": { "description": "A function to customize rendering of the component." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/data/translations/api-docs/scroll-area-scrollbar/scroll-area-scrollbar.json b/docs/data/translations/api-docs/scroll-area-scrollbar/scroll-area-scrollbar.json
new file mode 100644
index 0000000000..ececcec4f7
--- /dev/null
+++ b/docs/data/translations/api-docs/scroll-area-scrollbar/scroll-area-scrollbar.json
@@ -0,0 +1,14 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "keepMounted": {
+ "description": "Whether the scrollbars remain mounted in the DOM when there is no overflow."
+ },
+ "orientation": { "description": "The orientation of the scrollbar." },
+ "render": { "description": "A function to customize rendering of the component." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/data/translations/api-docs/scroll-area-thumb/scroll-area-thumb.json b/docs/data/translations/api-docs/scroll-area-thumb/scroll-area-thumb.json
new file mode 100644
index 0000000000..4bc12cf1e0
--- /dev/null
+++ b/docs/data/translations/api-docs/scroll-area-thumb/scroll-area-thumb.json
@@ -0,0 +1,10 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "render": { "description": "A function to customize rendering of the component." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/data/translations/api-docs/scroll-area-viewport/scroll-area-viewport.json b/docs/data/translations/api-docs/scroll-area-viewport/scroll-area-viewport.json
new file mode 100644
index 0000000000..4bc12cf1e0
--- /dev/null
+++ b/docs/data/translations/api-docs/scroll-area-viewport/scroll-area-viewport.json
@@ -0,0 +1,10 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "render": { "description": "A function to customize rendering of the component." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/src/styles/reset.css b/docs/src/styles/reset.css
index 7b809609a1..b578b16a7c 100644
--- a/docs/src/styles/reset.css
+++ b/docs/src/styles/reset.css
@@ -4,6 +4,12 @@ body {
padding-top: 49px;
}
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
::selection {
background: var(--gray-container-3);
}
diff --git a/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx
new file mode 100644
index 0000000000..dd185c84b3
--- /dev/null
+++ b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.test.tsx
@@ -0,0 +1,40 @@
+import * as React from 'react';
+import { ScrollArea } from '@base_ui/react/ScrollArea';
+import { expect } from 'chai';
+import { screen, describeSkipIf } from '@mui/internal-test-utils';
+import { createRenderer } from '#test-utils';
+import { describeConformance } from '../../../test/describeConformance';
+
+const isJSDOM = /jsdom/.test(window.navigator.userAgent);
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ describeConformance(, () => ({
+ refInstanceof: window.HTMLDivElement,
+ render(node) {
+ return render({node});
+ },
+ }));
+
+ describeSkipIf(isJSDOM)('interactions', () => {
+ it('should apply correct corner size when both scrollbars are present', async () => {
+ await render(
+
+
+
+
+
+
+
+ ,
+ );
+
+ const corner = screen.getByTestId('corner');
+ const style = getComputedStyle(corner);
+
+ expect(style.getPropertyValue('--scroll-area-corner-width')).to.equal('10px');
+ expect(style.getPropertyValue('--scroll-area-corner-height')).to.equal('10px');
+ });
+ });
+});
diff --git a/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.tsx b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.tsx
new file mode 100644
index 0000000000..1dd3a9099a
--- /dev/null
+++ b/packages/mui-base/src/ScrollArea/Corner/ScrollAreaCorner.tsx
@@ -0,0 +1,80 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import type { BaseUIComponentProps } from '../../utils/types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import { useScrollAreaRootContext } from '../Root/ScrollAreaRootContext';
+import { useForkRef } from '../../utils/useForkRef';
+
+const ownerState = {};
+
+/**
+ *
+ * Demos:
+ *
+ * - [Scroll Area](https://base-ui.netlify.app/components/react-scroll-area/)
+ *
+ * API:
+ *
+ * - [ScrollAreaCorner API](https://base-ui.netlify.app/components/react-scroll-area/#api-reference-ScrollAreaCorner)
+ */
+const ScrollAreaCorner = React.forwardRef(function ScrollAreaCorner(
+ props: ScrollAreaCorner.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const { render, className, ...otherProps } = props;
+
+ const { dir, cornerRef, cornerSize, hiddenState } = useScrollAreaRootContext();
+
+ const mergedRef = useForkRef(cornerRef, forwardedRef);
+
+ const { renderElement } = useComponentRenderer({
+ render: render ?? 'div',
+ ref: mergedRef,
+ className,
+ ownerState,
+ extraProps: mergeReactProps(otherProps, {
+ style: {
+ position: 'absolute',
+ bottom: 0,
+ [dir === 'rtl' ? 'left' : 'right']: 0,
+ width: cornerSize.width,
+ height: cornerSize.height,
+ },
+ }),
+ });
+
+ if (hiddenState.cornerHidden) {
+ return null;
+ }
+
+ return renderElement();
+});
+
+namespace ScrollAreaCorner {
+ export interface OwnerState {}
+
+ export interface Props extends BaseUIComponentProps<'div', OwnerState> {}
+}
+
+ScrollAreaCorner.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+} as any;
+
+export { ScrollAreaCorner };
diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx
new file mode 100644
index 0000000000..85c0b95d0c
--- /dev/null
+++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.test.tsx
@@ -0,0 +1,135 @@
+import * as React from 'react';
+import { ScrollArea } from '@base_ui/react/ScrollArea';
+import { screen, describeSkipIf } from '@mui/internal-test-utils';
+import { createRenderer } from '#test-utils';
+import { expect } from 'chai';
+import { describeConformance } from '../../../test/describeConformance';
+
+const isJSDOM = /jsdom/.test(window.navigator.userAgent);
+
+const VIEWPORT_SIZE = 200;
+const SCROLLABLE_CONTENT_SIZE = 1000;
+const SCROLLBAR_WIDTH = 10;
+const SCROLLBAR_HEIGHT = 10;
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ describeConformance(, () => ({
+ refInstanceof: window.HTMLDivElement,
+ render,
+ }));
+
+ describeSkipIf(isJSDOM)('interactions', () => {
+ it('should correctly set thumb height and width based on scrollable content', async () => {
+ await render(
+
+
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ const verticalThumb = screen.getByTestId('vertical-thumb');
+ const horizontalThumb = screen.getByTestId('horizontal-thumb');
+
+ expect(
+ getComputedStyle(verticalThumb).getPropertyValue('--scroll-area-thumb-height'),
+ ).to.equal(`${(VIEWPORT_SIZE / SCROLLABLE_CONTENT_SIZE) * VIEWPORT_SIZE}px`);
+ expect(
+ getComputedStyle(horizontalThumb).getPropertyValue('--scroll-area-thumb-width'),
+ ).to.equal(`${(VIEWPORT_SIZE / SCROLLABLE_CONTENT_SIZE) * VIEWPORT_SIZE}px`);
+ });
+
+ describe('prop: gutter', () => {
+ it('should not add padding for overlay scrollbars', async () => {
+ await render(
+
+
+
+
+
+
+ ,
+ );
+
+ const contentWrapper = screen.getByTestId('viewport').firstElementChild!;
+ const style = getComputedStyle(contentWrapper);
+
+ expect(style.paddingLeft).to.equal('0px');
+ expect(style.paddingRight).to.equal('0px');
+ expect(style.paddingBottom).to.equal('0px');
+ });
+
+ it('should add padding for inset scrollbars', async () => {
+ await render(
+
+
+
+
+
+
+ ,
+ );
+
+ const contentWrapper = screen.getByTestId('viewport').firstElementChild!;
+ const style = getComputedStyle(contentWrapper);
+
+ expect(style.paddingRight).to.equal(`${SCROLLBAR_WIDTH}px`);
+ expect(style.paddingBottom).to.equal(`${SCROLLBAR_HEIGHT}px`);
+ });
+ });
+
+ describe('prop: dir', () => {
+ it('should adjust inset padding for rtl', async () => {
+ await render(
+
+
+
+
+
+
+ ,
+ );
+
+ const contentWrapper = screen.getByTestId('viewport').firstElementChild!;
+ const style = getComputedStyle(contentWrapper);
+
+ expect(style.paddingLeft).to.equal(`${SCROLLBAR_WIDTH}px`);
+ expect(style.paddingRight).not.to.equal(`${SCROLLBAR_WIDTH}px`);
+ expect(style.paddingBottom).to.equal(`${SCROLLBAR_HEIGHT}px`);
+ });
+ });
+ });
+});
diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx
new file mode 100644
index 0000000000..9b23399f37
--- /dev/null
+++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRoot.tsx
@@ -0,0 +1,111 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import type { BaseUIComponentProps } from '../../utils/types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { ScrollAreaRootContext } from './ScrollAreaRootContext';
+import { useScrollAreaRoot } from './useScrollAreaRoot';
+
+const ownerState = {};
+
+/**
+ *
+ * Demos:
+ *
+ * - [Scroll Area](https://base-ui.netlify.app/components/react-scroll-area/)
+ *
+ * API:
+ *
+ * - [ScrollAreaRoot API](https://base-ui.netlify.app/components/react-scroll-area/#api-reference-ScrollAreaRoot)
+ */
+const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot(
+ props: ScrollAreaRoot.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const { render, className, dir, gutter = 0, ...otherProps } = props;
+
+ const scrollAreaRoot = useScrollAreaRoot({ dir, gutter });
+
+ const { rootId } = scrollAreaRoot;
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: scrollAreaRoot.getRootProps,
+ render: render ?? 'div',
+ ref: forwardedRef,
+ className,
+ ownerState,
+ extraProps: otherProps,
+ });
+
+ const contextValue = React.useMemo(
+ () => ({
+ dir,
+ gutter,
+ ...scrollAreaRoot,
+ }),
+ [dir, gutter, scrollAreaRoot],
+ );
+
+ const viewportId = `[data-id="${rootId}-viewport"]`;
+
+ const html = React.useMemo(
+ () => ({
+ __html: `${viewportId}{scrollbar-width:none}${viewportId}::-webkit-scrollbar{display:none}`,
+ }),
+ [viewportId],
+ );
+
+ return (
+
+ {rootId && (
+
+ )}
+ {renderElement()}
+
+ );
+});
+
+namespace ScrollAreaRoot {
+ export interface Props extends BaseUIComponentProps<'div', OwnerState> {
+ /**
+ * Determines the space to account for inset scrollbars.
+ * @default 0
+ */
+ gutter?: number | string;
+ }
+
+ export interface OwnerState {}
+}
+
+ScrollAreaRoot.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * @ignore
+ */
+ dir: PropTypes.string,
+ /**
+ * Determines the space to account for inset scrollbars.
+ * @default 0
+ */
+ gutter: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+} as any;
+
+export { ScrollAreaRoot };
diff --git a/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts
new file mode 100644
index 0000000000..23964499f1
--- /dev/null
+++ b/packages/mui-base/src/ScrollArea/Root/ScrollAreaRootContext.ts
@@ -0,0 +1,56 @@
+import * as React from 'react';
+
+export interface ScrollAreaRootContext {
+ dir: string | undefined;
+ gutter: number | string;
+ cornerSize: { width: number; height: number };
+ setCornerSize: React.Dispatch>;
+ thumbSize: { width: number; height: number };
+ setThumbSize: React.Dispatch>;
+ touchModality: boolean;
+ hovering: boolean;
+ setHovering: React.Dispatch>;
+ scrolling: boolean;
+ setScrolling: React.Dispatch>;
+ viewportRef: React.RefObject;
+ scrollbarYRef: React.RefObject;
+ thumbYRef: React.RefObject;
+ scrollbarXRef: React.RefObject;
+ thumbXRef: React.RefObject;
+ cornerRef: React.RefObject;
+ handlePointerDown: (event: React.PointerEvent) => void;
+ handlePointerMove: (event: React.PointerEvent) => void;
+ handlePointerUp: (event: React.PointerEvent) => void;
+ handleScroll: () => void;
+ rootId: string | undefined;
+ hiddenState: {
+ scrollbarYHidden: boolean;
+ scrollbarXHidden: boolean;
+ cornerHidden: boolean;
+ };
+ setHiddenState: React.Dispatch<
+ React.SetStateAction<{
+ scrollbarYHidden: boolean;
+ scrollbarXHidden: boolean;
+ cornerHidden: boolean;
+ }>
+ >;
+}
+
+export const ScrollAreaRootContext = React.createContext(
+ undefined,
+);
+
+if (process.env.NODE_ENV !== 'production') {
+ ScrollAreaRootContext.displayName = 'ScrollAreaRootContext';
+}
+
+export function useScrollAreaRootContext() {
+ const context = React.useContext(ScrollAreaRootContext);
+ if (context === undefined) {
+ throw new Error(
+ 'Base UI: ScrollAreaRootContext is missing. ScrollArea parts must be placed within .',
+ );
+ }
+ return context;
+}
diff --git a/packages/mui-base/src/ScrollArea/Root/useScrollAreaRoot.ts b/packages/mui-base/src/ScrollArea/Root/useScrollAreaRoot.ts
new file mode 100644
index 0000000000..3ad05cf104
--- /dev/null
+++ b/packages/mui-base/src/ScrollArea/Root/useScrollAreaRoot.ts
@@ -0,0 +1,238 @@
+import * as React from 'react';
+import { useEventCallback } from '../../utils/useEventCallback';
+import { useEnhancedEffect } from '../../utils/useEnhancedEffect';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import { useId } from '../../utils/useId';
+import { SCROLL_TIMEOUT } from '../constants';
+
+interface Size {
+ width: number;
+ height: number;
+}
+
+export function useScrollAreaRoot(params: useScrollAreaRoot.Parameters) {
+ const { dir: dirParam } = params;
+
+ const [hovering, setHovering] = React.useState(false);
+ const [scrolling, setScrolling] = React.useState(false);
+ const [cornerSize, setCornerSize] = React.useState({ width: 0, height: 0 });
+ const [thumbSize, setThumbSize] = React.useState({ width: 0, height: 0 });
+ const [touchModality, setTouchModality] = React.useState(false);
+
+ const rootId = useId();
+
+ const viewportRef = React.useRef(null);
+ const scrollbarYRef = React.useRef(null);
+ const scrollbarXRef = React.useRef(null);
+ const thumbYRef = React.useRef(null);
+ const thumbXRef = React.useRef(null);
+ const cornerRef = React.useRef(null);
+
+ const thumbDraggingRef = React.useRef(false);
+ const startYRef = React.useRef(0);
+ const startXRef = React.useRef(0);
+ const startScrollTopRef = React.useRef(0);
+ const startScrollLeftRef = React.useRef(0);
+ const currentOrientationRef = React.useRef<'vertical' | 'horizontal'>('vertical');
+ const timeoutRef = React.useRef(-1);
+
+ const [hiddenState, setHiddenState] = React.useState({
+ scrollbarYHidden: false,
+ scrollbarXHidden: false,
+ cornerHidden: false,
+ });
+
+ const [autoDir, setAutoDir] = React.useState(dirParam);
+ const dir = dirParam ?? autoDir;
+
+ useEnhancedEffect(() => {
+ if (dirParam === undefined && viewportRef.current) {
+ setAutoDir(getComputedStyle(viewportRef.current).direction);
+ }
+ }, [dirParam]);
+
+ React.useEffect(() => {
+ return () => {
+ window.clearTimeout(timeoutRef.current);
+ };
+ }, []);
+
+ const handleScroll = useEventCallback(() => {
+ setScrolling(true);
+
+ window.clearTimeout(timeoutRef.current);
+ timeoutRef.current = window.setTimeout(() => {
+ setScrolling(false);
+ }, SCROLL_TIMEOUT);
+ });
+
+ const handlePointerDown = useEventCallback((event: React.PointerEvent) => {
+ thumbDraggingRef.current = true;
+ startYRef.current = event.clientY;
+ startXRef.current = event.clientX;
+ currentOrientationRef.current = event.currentTarget.getAttribute('data-orientation') as
+ | 'vertical'
+ | 'horizontal';
+
+ if (viewportRef.current) {
+ startScrollTopRef.current = viewportRef.current.scrollTop;
+ startScrollLeftRef.current = viewportRef.current.scrollLeft;
+ }
+ if (thumbYRef.current && currentOrientationRef.current === 'vertical') {
+ thumbYRef.current.setPointerCapture(event.pointerId);
+ }
+ if (thumbXRef.current && currentOrientationRef.current === 'horizontal') {
+ thumbXRef.current.setPointerCapture(event.pointerId);
+ }
+ });
+
+ const handlePointerMove = useEventCallback((event: React.PointerEvent) => {
+ if (!thumbDraggingRef.current) {
+ return;
+ }
+
+ const deltaY = event.clientY - startYRef.current;
+ const deltaX = event.clientX - startXRef.current;
+
+ if (viewportRef.current) {
+ const scrollableContentHeight = viewportRef.current.scrollHeight;
+ const viewportHeight = viewportRef.current.clientHeight;
+ const scrollableContentWidth = viewportRef.current.scrollWidth;
+ const viewportWidth = viewportRef.current.clientWidth;
+
+ if (
+ thumbYRef.current &&
+ scrollbarYRef.current &&
+ currentOrientationRef.current === 'vertical'
+ ) {
+ const thumbHeight = thumbYRef.current.offsetHeight;
+ const maxThumbOffsetY = scrollbarYRef.current.offsetHeight - thumbHeight;
+ const scrollRatioY = deltaY / maxThumbOffsetY;
+ viewportRef.current.scrollTop =
+ startScrollTopRef.current + scrollRatioY * (scrollableContentHeight - viewportHeight);
+ event.preventDefault();
+ setScrolling(true);
+ window.clearTimeout(timeoutRef.current);
+ timeoutRef.current = window.setTimeout(() => {
+ setScrolling(false);
+ }, SCROLL_TIMEOUT);
+ }
+
+ if (
+ thumbXRef.current &&
+ scrollbarXRef.current &&
+ currentOrientationRef.current === 'horizontal'
+ ) {
+ const thumbWidth = thumbXRef.current.offsetWidth;
+ const maxThumbOffsetX = scrollbarXRef.current.offsetWidth - thumbWidth;
+ const scrollRatioX = deltaX / maxThumbOffsetX;
+ viewportRef.current.scrollLeft =
+ startScrollLeftRef.current + scrollRatioX * (scrollableContentWidth - viewportWidth);
+ event.preventDefault();
+ setScrolling(true);
+ window.clearTimeout(timeoutRef.current);
+ timeoutRef.current = window.setTimeout(() => {
+ setScrolling(false);
+ }, SCROLL_TIMEOUT);
+ }
+ }
+ });
+
+ const handlePointerUp = useEventCallback((event: React.PointerEvent) => {
+ thumbDraggingRef.current = false;
+
+ if (thumbYRef.current && currentOrientationRef.current === 'vertical') {
+ thumbYRef.current.releasePointerCapture(event.pointerId);
+ }
+ if (thumbXRef.current && currentOrientationRef.current === 'horizontal') {
+ thumbXRef.current.releasePointerCapture(event.pointerId);
+ }
+ });
+
+ const handlePointerEnterOrMove = useEventCallback(({ pointerType }: React.PointerEvent) => {
+ const isTouch = pointerType === 'touch';
+
+ setTouchModality(isTouch);
+
+ if (!isTouch) {
+ setHovering(true);
+ }
+ });
+
+ const getRootProps = React.useCallback(
+ (externalProps = {}) =>
+ mergeReactProps<'div'>(externalProps, {
+ dir,
+ onPointerEnter: handlePointerEnterOrMove,
+ onPointerMove: handlePointerEnterOrMove,
+ onPointerDown({ pointerType }) {
+ setTouchModality(pointerType === 'touch');
+ },
+ onPointerLeave() {
+ setHovering(false);
+ },
+ style: {
+ position: 'relative',
+ ['--scroll-area-corner-width' as string]: `${cornerSize.width}px`,
+ ['--scroll-area-corner-height' as string]: `${cornerSize.height}px`,
+ },
+ }),
+ [cornerSize, dir, handlePointerEnterOrMove],
+ );
+
+ return React.useMemo(
+ () => ({
+ getRootProps,
+ handlePointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ handleScroll,
+ cornerSize,
+ setCornerSize,
+ thumbSize,
+ setThumbSize,
+ touchModality,
+ cornerRef,
+ scrolling,
+ setScrolling,
+ hovering,
+ setHovering,
+ viewportRef,
+ scrollbarYRef,
+ scrollbarXRef,
+ thumbYRef,
+ thumbXRef,
+ rootId,
+ hiddenState,
+ setHiddenState,
+ }),
+ [
+ getRootProps,
+ handlePointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ handleScroll,
+ cornerSize,
+ thumbSize,
+ touchModality,
+ cornerRef,
+ scrolling,
+ hovering,
+ setHovering,
+ viewportRef,
+ scrollbarYRef,
+ scrollbarXRef,
+ thumbYRef,
+ thumbXRef,
+ rootId,
+ hiddenState,
+ ],
+ );
+}
+
+export namespace useScrollAreaRoot {
+ export interface Parameters {
+ dir: string | undefined;
+ gutter: number | string;
+ }
+}
diff --git a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx
new file mode 100644
index 0000000000..0eb2297c52
--- /dev/null
+++ b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.test.tsx
@@ -0,0 +1,90 @@
+import * as React from 'react';
+import { ScrollArea } from '@base_ui/react/ScrollArea';
+import { screen, fireEvent } from '@mui/internal-test-utils';
+import { createRenderer } from '#test-utils';
+import { expect } from 'chai';
+import { describeConformance } from '../../../test/describeConformance';
+import { SCROLL_TIMEOUT } from '../constants';
+
+describe('', () => {
+ const { render, clock } = createRenderer();
+
+ clock.withFakeTimers();
+
+ describeConformance(, () => ({
+ refInstanceof: window.HTMLDivElement,
+ render(node) {
+ return render({node});
+ },
+ }));
+
+ it('adds [data-hovering] attribute when viewport is hovered', async function test(t = {}) {
+ // Fails to pass in browser CI, but works locally
+ if (!/jsdom/.test(window.navigator.userAgent)) {
+ // @ts-expect-error to support mocha and vitest
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+ this?.skip?.() || t?.skip();
+ }
+
+ await render(
+
+
+
+
+
+
+
+ ,
+ );
+
+ const verticalScrollbar = screen.getByTestId('vertical');
+ const horizontalScrollbar = screen.getByTestId('horizontal');
+
+ expect(verticalScrollbar).not.to.have.attribute('data-hovering');
+ expect(horizontalScrollbar).not.to.have.attribute('data-hovering');
+
+ fireEvent.pointerEnter(screen.getByTestId('root'), { pointerType: 'mouse' });
+
+ expect(verticalScrollbar).to.have.attribute('data-hovering', '');
+ expect(horizontalScrollbar).to.have.attribute('data-hovering', '');
+
+ fireEvent.pointerLeave(screen.getByTestId('root'), { pointerType: 'mouse' });
+
+ expect(verticalScrollbar).not.to.have.attribute('data-hovering');
+ expect(horizontalScrollbar).not.to.have.attribute('data-hovering');
+ });
+
+ it('adds [data-scrolling] attribute when viewport is scrolled', async () => {
+ await render(
+
+
+
+
+
+
+
+ ,
+ );
+
+ const verticalScrollbar = screen.getByTestId('vertical');
+ const horizontalScrollbar = screen.getByTestId('horizontal');
+
+ expect(verticalScrollbar).not.to.have.attribute('data-scrolling');
+ expect(horizontalScrollbar).not.to.have.attribute('data-scrolling');
+
+ fireEvent.scroll(screen.getByTestId('viewport'));
+
+ expect(verticalScrollbar).to.have.attribute('data-scrolling', '');
+ expect(horizontalScrollbar).to.have.attribute('data-scrolling', '');
+
+ clock.tick(SCROLL_TIMEOUT - 1);
+
+ expect(verticalScrollbar).to.have.attribute('data-scrolling', '');
+ expect(horizontalScrollbar).to.have.attribute('data-scrolling', '');
+
+ clock.tick(1);
+
+ expect(verticalScrollbar).not.to.have.attribute('data-scrolling');
+ expect(horizontalScrollbar).not.to.have.attribute('data-scrolling');
+ });
+});
diff --git a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx
new file mode 100644
index 0000000000..a906f8604f
--- /dev/null
+++ b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbar.tsx
@@ -0,0 +1,124 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import type { BaseUIComponentProps } from '../../utils/types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { useScrollAreaRootContext } from '../Root/ScrollAreaRootContext';
+import { useForkRef } from '../../utils/useForkRef';
+import { ScrollAreaScrollbarContext } from './ScrollAreaScrollbarContext';
+import { useScrollAreaScrollbar } from './useScrollAreaScrollbar';
+
+/**
+ *
+ * Demos:
+ *
+ * - [Scroll Area](https://base-ui.netlify.app/components/react-scroll-area/)
+ *
+ * API:
+ *
+ * - [ScrollAreaScrollbar API](https://base-ui.netlify.app/components/react-scroll-area/#api-reference-ScrollAreaScrollbar)
+ */
+const ScrollAreaScrollbar = React.forwardRef(function ScrollAreaScrollbar(
+ props: ScrollAreaScrollbar.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const { render, className, orientation = 'vertical', keepMounted = false, ...otherProps } = props;
+
+ const { hovering, scrolling, hiddenState, scrollbarYRef, scrollbarXRef } =
+ useScrollAreaRootContext();
+
+ const mergedRef = useForkRef(
+ forwardedRef,
+ orientation === 'vertical' ? scrollbarYRef : scrollbarXRef,
+ );
+
+ const ownerState: ScrollAreaScrollbar.OwnerState = React.useMemo(
+ () => ({
+ hovering,
+ scrolling,
+ orientation,
+ }),
+ [hovering, scrolling, orientation],
+ );
+
+ const { getScrollbarProps } = useScrollAreaScrollbar({
+ orientation,
+ });
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getScrollbarProps,
+ render: render ?? 'div',
+ ref: mergedRef,
+ className,
+ ownerState,
+ extraProps: otherProps,
+ });
+
+ const contextValue = React.useMemo(() => ({ orientation }), [orientation]);
+
+ const isHidden =
+ orientation === 'vertical' ? hiddenState.scrollbarYHidden : hiddenState.scrollbarXHidden;
+
+ const shouldRender = keepMounted || !isHidden;
+ if (!shouldRender) {
+ return null;
+ }
+
+ return (
+
+ {renderElement()}
+
+ );
+});
+
+namespace ScrollAreaScrollbar {
+ export interface OwnerState {
+ hovering: boolean;
+ scrolling: boolean;
+ orientation: 'vertical' | 'horizontal';
+ }
+
+ export interface Props extends BaseUIComponentProps<'div', OwnerState> {
+ /**
+ * The orientation of the scrollbar.
+ * @default 'vertical'
+ */
+ orientation?: 'vertical' | 'horizontal';
+ /**
+ * Whether the scrollbars remain mounted in the DOM when there is no overflow.
+ * @default false
+ */
+ keepMounted?: boolean;
+ }
+}
+
+ScrollAreaScrollbar.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * Whether the scrollbars remain mounted in the DOM when there is no overflow.
+ * @default false
+ */
+ keepMounted: PropTypes.bool,
+ /**
+ * The orientation of the scrollbar.
+ * @default 'vertical'
+ */
+ orientation: PropTypes.oneOf(['horizontal', 'vertical']),
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+} as any;
+
+export { ScrollAreaScrollbar };
diff --git a/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbarContext.ts b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbarContext.ts
new file mode 100644
index 0000000000..a400a292ab
--- /dev/null
+++ b/packages/mui-base/src/ScrollArea/Scrollbar/ScrollAreaScrollbarContext.ts
@@ -0,0 +1,23 @@
+import * as React from 'react';
+
+export interface ScrollAreaScrollbarContext {
+ orientation: 'horizontal' | 'vertical';
+}
+
+export const ScrollAreaScrollbarContext = React.createContext<
+ ScrollAreaScrollbarContext | undefined
+>(undefined);
+
+if (process.env.NODE_ENV !== 'production') {
+ ScrollAreaScrollbarContext.displayName = 'ScrollAreaScrollbarContext';
+}
+
+export function useScrollAreaScrollbarContext() {
+ const context = React.useContext(ScrollAreaScrollbarContext);
+ if (context === undefined) {
+ throw new Error(
+ 'Base UI: ScrollAreaScrollbarContext is missing. ScrollAreaScrollbar parts must be placed within .',
+ );
+ }
+ return context;
+}
diff --git a/packages/mui-base/src/ScrollArea/Scrollbar/useScrollAreaScrollbar.ts b/packages/mui-base/src/ScrollArea/Scrollbar/useScrollAreaScrollbar.ts
new file mode 100644
index 0000000000..a771793352
--- /dev/null
+++ b/packages/mui-base/src/ScrollArea/Scrollbar/useScrollAreaScrollbar.ts
@@ -0,0 +1,177 @@
+import * as React from 'react';
+import { useScrollAreaRootContext } from '../Root/ScrollAreaRootContext';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+
+export function useScrollAreaScrollbar(params: useScrollAreaScrollbar.Parameters) {
+ const { orientation } = params;
+
+ const {
+ dir,
+ scrollbarYRef,
+ scrollbarXRef,
+ viewportRef,
+ thumbYRef,
+ thumbXRef,
+ handlePointerDown,
+ handlePointerUp,
+ rootId,
+ thumbSize,
+ } = useScrollAreaRootContext();
+
+ React.useEffect(() => {
+ const viewportEl = viewportRef.current;
+ const scrollbarEl = orientation === 'vertical' ? scrollbarYRef.current : scrollbarXRef.current;
+
+ if (!scrollbarEl) {
+ return undefined;
+ }
+
+ function handleWheel(event: WheelEvent) {
+ if (!viewportEl || !scrollbarEl || event.ctrlKey) {
+ return;
+ }
+
+ event.preventDefault();
+
+ if (orientation === 'vertical') {
+ if (viewportEl.scrollTop === 0 && event.deltaY < 0) {
+ return;
+ }
+ } else if (viewportEl.scrollLeft === 0 && event.deltaX < 0) {
+ return;
+ }
+
+ if (orientation === 'vertical') {
+ if (
+ viewportEl.scrollTop === viewportEl.scrollHeight - viewportEl.clientHeight &&
+ event.deltaY > 0
+ ) {
+ return;
+ }
+ } else if (
+ viewportEl.scrollLeft === viewportEl.scrollWidth - viewportEl.clientWidth &&
+ event.deltaX > 0
+ ) {
+ return;
+ }
+
+ if (orientation === 'vertical') {
+ viewportEl.scrollTop += event.deltaY;
+ } else {
+ viewportEl.scrollLeft += event.deltaX;
+ }
+ }
+
+ scrollbarEl.addEventListener('wheel', handleWheel, { passive: false });
+
+ return () => {
+ scrollbarEl.removeEventListener('wheel', handleWheel);
+ };
+ }, [orientation, scrollbarXRef, scrollbarYRef, viewportRef]);
+
+ const getScrollbarProps = React.useCallback(
+ (externalProps = {}) =>
+ mergeReactProps<'div'>(externalProps, {
+ ...(rootId && { 'data-id': `${rootId}-scrollbar` }),
+ onPointerDown(event) {
+ // Ignore clicks on thumb
+ if (event.currentTarget !== event.target) {
+ return;
+ }
+
+ if (!viewportRef.current) {
+ return;
+ }
+
+ // Handle Y-axis (vertical) scroll
+ if (thumbYRef.current && scrollbarYRef.current && orientation === 'vertical') {
+ const thumbHeight = thumbYRef.current.offsetHeight;
+ const trackRectY = scrollbarYRef.current.getBoundingClientRect();
+ const clickY = event.clientY - trackRectY.top - thumbHeight / 2;
+
+ const scrollableContentHeight = viewportRef.current.scrollHeight;
+ const viewportHeight = viewportRef.current.clientHeight;
+
+ const maxThumbOffsetY = scrollbarYRef.current.offsetHeight - thumbHeight;
+ const scrollRatioY = clickY / maxThumbOffsetY;
+ const newScrollTop = scrollRatioY * (scrollableContentHeight - viewportHeight);
+
+ viewportRef.current.scrollTop = newScrollTop;
+ }
+
+ if (thumbXRef.current && scrollbarXRef.current && orientation === 'horizontal') {
+ const thumbWidth = thumbXRef.current.offsetWidth;
+ const trackRectX = scrollbarXRef.current.getBoundingClientRect();
+ const clickX = event.clientX - trackRectX.left - thumbWidth / 2;
+
+ const scrollableContentWidth = viewportRef.current.scrollWidth;
+ const viewportWidth = viewportRef.current.clientWidth;
+
+ const maxThumbOffsetX = scrollbarXRef.current.offsetWidth - thumbWidth;
+ const scrollRatioX = clickX / maxThumbOffsetX;
+
+ let newScrollLeft: number;
+ if (dir === 'rtl') {
+ // In RTL, invert the scroll direction
+ newScrollLeft = (1 - scrollRatioX) * (scrollableContentWidth - viewportWidth);
+
+ // Adjust for browsers that use negative scrollLeft in RTL
+ if (viewportRef.current.scrollLeft <= 0) {
+ newScrollLeft = -newScrollLeft;
+ }
+ } else {
+ newScrollLeft = scrollRatioX * (scrollableContentWidth - viewportWidth);
+ }
+
+ viewportRef.current.scrollLeft = newScrollLeft;
+ }
+
+ handlePointerDown(event);
+ },
+ onPointerUp: handlePointerUp,
+ style: {
+ position: 'absolute',
+ touchAction: 'none',
+ ...(orientation === 'vertical' && {
+ top: 0,
+ bottom: 'var(--scroll-area-corner-height)',
+ [dir === 'rtl' ? 'left' : 'right']: 0,
+ ['--scroll-area-thumb-height' as string]: `${thumbSize.height}px`,
+ }),
+ ...(orientation === 'horizontal' && {
+ [dir === 'rtl' ? 'right' : 'left']: 0,
+ [dir === 'rtl' ? 'left' : 'right']: 'var(--scroll-area-corner-width)',
+ bottom: 0,
+ ['--scroll-area-thumb-width' as string]: `${thumbSize.width}px`,
+ }),
+ },
+ }),
+ [
+ rootId,
+ handlePointerUp,
+ orientation,
+ dir,
+ thumbSize.height,
+ thumbSize.width,
+ viewportRef,
+ thumbYRef,
+ scrollbarYRef,
+ thumbXRef,
+ scrollbarXRef,
+ handlePointerDown,
+ ],
+ );
+
+ return React.useMemo(
+ () => ({
+ getScrollbarProps,
+ }),
+ [getScrollbarProps],
+ );
+}
+
+export namespace useScrollAreaScrollbar {
+ export interface Parameters {
+ orientation: 'vertical' | 'horizontal';
+ }
+}
diff --git a/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx b/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx
new file mode 100644
index 0000000000..2d17485e26
--- /dev/null
+++ b/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.test.tsx
@@ -0,0 +1,19 @@
+import * as React from 'react';
+import { createRenderer } from '#test-utils';
+import { ScrollArea } from '@base_ui/react/ScrollArea';
+import { describeConformance } from '../../../test/describeConformance';
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ describeConformance(, () => ({
+ refInstanceof: window.HTMLDivElement,
+ render(node) {
+ return render(
+
+ {node}
+ ,
+ );
+ },
+ }));
+});
diff --git a/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.tsx b/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.tsx
new file mode 100644
index 0000000000..61cf24f80b
--- /dev/null
+++ b/packages/mui-base/src/ScrollArea/Thumb/ScrollAreaThumb.tsx
@@ -0,0 +1,94 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import type { BaseUIComponentProps } from '../../utils/types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { useScrollAreaRootContext } from '../Root/ScrollAreaRootContext';
+import { useForkRef } from '../../utils/useForkRef';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import { useScrollAreaScrollbarContext } from '../Scrollbar/ScrollAreaScrollbarContext';
+
+/**
+ *
+ * Demos:
+ *
+ * - [Scroll Area](https://base-ui.netlify.app/components/react-scroll-area/)
+ *
+ * API:
+ *
+ * - [ScrollAreaThumb API](https://base-ui.netlify.app/components/react-scroll-area/#api-reference-ScrollAreaThumb)
+ */
+const ScrollAreaThumb = React.forwardRef(function ScrollAreaThumb(
+ props: ScrollAreaThumb.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const { render, className, ...otherProps } = props;
+
+ const {
+ thumbYRef,
+ thumbXRef,
+ handlePointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ setScrolling,
+ } = useScrollAreaRootContext();
+
+ const { orientation } = useScrollAreaScrollbarContext();
+
+ const mergedRef = useForkRef(forwardedRef, orientation === 'vertical' ? thumbYRef : thumbXRef);
+
+ const ownerState: ScrollAreaThumb.OwnerState = React.useMemo(
+ () => ({ orientation }),
+ [orientation],
+ );
+
+ const { renderElement } = useComponentRenderer({
+ render: render ?? 'div',
+ ref: mergedRef,
+ className,
+ ownerState,
+ extraProps: mergeReactProps<'div'>(otherProps, {
+ onPointerDown: handlePointerDown,
+ onPointerMove: handlePointerMove,
+ onPointerUp(event) {
+ setScrolling(false);
+ handlePointerUp(event);
+ },
+ style: {
+ ...(orientation === 'vertical' && { height: 'var(--scroll-area-thumb-height)' }),
+ ...(orientation === 'horizontal' && { width: 'var(--scroll-area-thumb-width)' }),
+ },
+ }),
+ });
+
+ return renderElement();
+});
+
+namespace ScrollAreaThumb {
+ export interface OwnerState {
+ orientation?: 'horizontal' | 'vertical';
+ }
+
+ export interface Props extends BaseUIComponentProps<'div', OwnerState> {}
+}
+
+ScrollAreaThumb.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+} as any;
+
+export { ScrollAreaThumb };
diff --git a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx
new file mode 100644
index 0000000000..16a15c03ea
--- /dev/null
+++ b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.test.tsx
@@ -0,0 +1,15 @@
+import * as React from 'react';
+import { ScrollArea } from '@base_ui/react/ScrollArea';
+import { createRenderer } from '#test-utils';
+import { describeConformance } from '../../../test/describeConformance';
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ describeConformance(, () => ({
+ refInstanceof: window.HTMLDivElement,
+ render(node) {
+ return render({node});
+ },
+ }));
+});
diff --git a/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx
new file mode 100644
index 0000000000..ccd54a9532
--- /dev/null
+++ b/packages/mui-base/src/ScrollArea/Viewport/ScrollAreaViewport.tsx
@@ -0,0 +1,70 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import type { BaseUIComponentProps } from '../../utils/types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { useForkRef } from '../../utils/useForkRef';
+import { useScrollAreaRootContext } from '../Root/ScrollAreaRootContext';
+import { useScrollAreaViewport } from './useScrollAreaViewport';
+
+const ownerState = {};
+
+/**
+ *
+ * Demos:
+ *
+ * - [Scroll Area](https://base-ui.netlify.app/components/react-scroll-area/)
+ *
+ * API:
+ *
+ * - [ScrollAreaViewport API](https://base-ui.netlify.app/components/react-scroll-area/#api-reference-ScrollAreaViewport)
+ */
+const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport(
+ props: ScrollAreaViewport.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const { render, className, children, ...otherProps } = props;
+
+ const { viewportRef } = useScrollAreaRootContext();
+ const { getViewportProps } = useScrollAreaViewport({ children });
+
+ const mergedRef = useForkRef(forwardedRef, viewportRef);
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getViewportProps,
+ render: render ?? 'div',
+ ref: mergedRef,
+ className,
+ ownerState,
+ extraProps: otherProps,
+ });
+
+ return renderElement();
+});
+
+namespace ScrollAreaViewport {
+ export interface Props extends BaseUIComponentProps<'div', OwnerState> {}
+
+ export interface OwnerState {}
+}
+
+ScrollAreaViewport.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+} as any;
+
+export { ScrollAreaViewport };
diff --git a/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx b/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx
new file mode 100644
index 0000000000..57694594c2
--- /dev/null
+++ b/packages/mui-base/src/ScrollArea/Viewport/useScrollAreaViewport.tsx
@@ -0,0 +1,236 @@
+import * as React from 'react';
+import { useScrollAreaRootContext } from '../Root/ScrollAreaRootContext';
+import { useEventCallback } from '../../utils/useEventCallback';
+import { useEnhancedEffect } from '../../utils/useEnhancedEffect';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import { MIN_THUMB_SIZE } from '../constants';
+
+export function useScrollAreaViewport(params: useScrollAreaViewport.Parameters) {
+ const { children } = params;
+
+ const {
+ viewportRef,
+ scrollbarYRef,
+ scrollbarXRef,
+ thumbYRef,
+ thumbXRef,
+ cornerRef,
+ dir,
+ gutter,
+ setCornerSize,
+ setThumbSize,
+ rootId,
+ setHiddenState,
+ hiddenState,
+ handleScroll,
+ } = useScrollAreaRootContext();
+
+ const contentWrapperRef = React.useRef(null);
+
+ const computeThumb = useEventCallback(() => {
+ const viewportEl = viewportRef.current;
+ const scrollbarYEl = scrollbarYRef.current;
+ const scrollbarXEl = scrollbarXRef.current;
+ const thumbYEl = thumbYRef.current;
+ const thumbXEl = thumbXRef.current;
+ const cornerEl = cornerRef.current;
+
+ if (!viewportEl) {
+ return;
+ }
+
+ const scrollableContentHeight = viewportEl.scrollHeight;
+ const scrollableContentWidth = viewportEl.scrollWidth;
+ const viewportHeight = viewportEl.clientHeight;
+ const viewportWidth = viewportEl.clientWidth;
+ const scrollTop = viewportEl.scrollTop;
+ const scrollLeft = viewportEl.scrollLeft;
+
+ const scrollbarYHidden = viewportHeight >= scrollableContentHeight;
+ const scrollbarXHidden = viewportWidth >= scrollableContentWidth;
+
+ const nextWidth = scrollbarXHidden
+ ? 0
+ : (viewportWidth / scrollableContentWidth) * viewportWidth;
+ const nextHeight = scrollbarYHidden
+ ? 0
+ : (viewportHeight / scrollableContentHeight) * viewportHeight;
+
+ const clampedNextWidth = Math.max(MIN_THUMB_SIZE, nextWidth);
+ const clampedNextHeight = Math.max(MIN_THUMB_SIZE, nextHeight);
+
+ setThumbSize((prevSize) => {
+ if (prevSize.height === clampedNextHeight && prevSize.width === clampedNextWidth) {
+ return prevSize;
+ }
+
+ return {
+ width: clampedNextWidth,
+ height: clampedNextHeight,
+ };
+ });
+
+ // Handle Y (vertical) scroll
+ if (scrollbarYEl && thumbYEl) {
+ const scrollbarStylesY = getComputedStyle(scrollbarYEl);
+ const paddingTop = parseFloat(scrollbarStylesY.paddingTop);
+ const paddingBottom = parseFloat(scrollbarStylesY.paddingBottom);
+
+ const maxThumbOffsetY =
+ scrollbarYEl.offsetHeight - clampedNextHeight - (paddingTop + paddingBottom);
+ const scrollRatioY = scrollTop / (scrollableContentHeight - viewportHeight);
+
+ // In Safari, don't allow it to go negative or too far as `scrollTop` considers the rubber
+ // band effect.
+ const thumbOffsetY = Math.min(maxThumbOffsetY, Math.max(0, scrollRatioY * maxThumbOffsetY));
+
+ thumbYEl.style.transform = `translate3d(0,${thumbOffsetY}px,0)`;
+ }
+
+ // Handle X (horizontal) scroll
+ if (scrollbarXEl && thumbXEl) {
+ const scrollbarStylesX = getComputedStyle(scrollbarXEl);
+ const paddingLeft = parseFloat(scrollbarStylesX.paddingLeft);
+ const paddingRight = parseFloat(scrollbarStylesX.paddingRight);
+
+ const maxThumbOffsetX =
+ scrollbarXEl.offsetWidth - clampedNextWidth - (paddingLeft + paddingRight);
+ const scrollRatioX = scrollLeft / (scrollableContentWidth - viewportWidth);
+
+ const clamp = (value: number, min: number, max: number) =>
+ Math.min(Math.max(value, min), max);
+
+ // In Safari, don't allow it to go negative or too far as `scrollLeft` considers the rubber
+ // band effect.
+ const thumbOffsetX =
+ dir === 'rtl'
+ ? clamp(scrollRatioX * maxThumbOffsetX, -maxThumbOffsetX, 0)
+ : clamp(scrollRatioX * maxThumbOffsetX, 0, maxThumbOffsetX);
+
+ thumbXEl.style.transform = `translate3d(${thumbOffsetX}px,0,0)`;
+ }
+
+ if (cornerEl) {
+ if (scrollbarXHidden || scrollbarYHidden) {
+ setCornerSize({ width: 0, height: 0 });
+ } else if (!scrollbarXHidden && !scrollbarYHidden) {
+ const width = scrollbarYEl?.offsetWidth || 0;
+ const height = scrollbarXEl?.offsetHeight || 0;
+ setCornerSize({ width, height });
+ }
+ }
+
+ setHiddenState((prevState) => {
+ const cornerHidden = scrollbarYHidden || scrollbarXHidden;
+
+ if (
+ prevState.scrollbarYHidden === scrollbarYHidden &&
+ prevState.scrollbarXHidden === scrollbarXHidden &&
+ prevState.cornerHidden === cornerHidden
+ ) {
+ return prevState;
+ }
+
+ return {
+ scrollbarYHidden,
+ scrollbarXHidden,
+ cornerHidden,
+ };
+ });
+ });
+
+ useEnhancedEffect(() => {
+ // First load computation.
+ // Wait for the scrollbar-related refs to be set.
+ queueMicrotask(computeThumb);
+ }, [computeThumb]);
+
+ useEnhancedEffect(() => {
+ computeThumb();
+ }, [computeThumb, hiddenState, dir]);
+
+ React.useEffect(() => {
+ if (
+ !contentWrapperRef.current ||
+ !viewportRef.current ||
+ typeof ResizeObserver === 'undefined'
+ ) {
+ return undefined;
+ }
+
+ const ro = new ResizeObserver(computeThumb);
+ ro.observe(contentWrapperRef.current);
+ ro.observe(viewportRef.current);
+
+ return () => {
+ ro.disconnect();
+ };
+ }, [computeThumb, viewportRef]);
+
+ const wrapperStyles: React.CSSProperties = React.useMemo(() => {
+ const styles: React.CSSProperties = {};
+
+ if (!gutter) {
+ return styles;
+ }
+
+ // Unconditional for layout stability: prevent layout shifts when the vertical scrollbar is
+ // hidden/shown.
+ styles[dir === 'rtl' ? 'paddingLeft' : 'paddingRight'] = gutter;
+
+ if (!hiddenState.scrollbarXHidden) {
+ styles.paddingBottom = gutter;
+ }
+
+ return styles;
+ }, [hiddenState, dir, gutter]);
+
+ const getViewportProps = React.useCallback(
+ (externalProps = {}) =>
+ mergeReactProps<'div'>(externalProps, {
+ ...(rootId && { 'data-id': `${rootId}-viewport` }),
+ // https://accessibilityinsights.io/info-examples/web/scrollable-region-focusable/
+ ...((!hiddenState.scrollbarXHidden || !hiddenState.scrollbarYHidden) && { tabIndex: 0 }),
+ style: {
+ overflow: 'scroll',
+ },
+ onScroll() {
+ computeThumb();
+ handleScroll();
+ },
+ children: (
+
+ {children}
+
+ ),
+ }),
+ [
+ rootId,
+ hiddenState.scrollbarXHidden,
+ hiddenState.scrollbarYHidden,
+ wrapperStyles,
+ children,
+ computeThumb,
+ handleScroll,
+ ],
+ );
+
+ return React.useMemo(
+ () => ({
+ getViewportProps,
+ }),
+ [getViewportProps],
+ );
+}
+
+namespace useScrollAreaViewport {
+ export interface Parameters {
+ children?: React.ReactNode;
+ }
+}
diff --git a/packages/mui-base/src/ScrollArea/constants.ts b/packages/mui-base/src/ScrollArea/constants.ts
new file mode 100644
index 0000000000..1372a9533a
--- /dev/null
+++ b/packages/mui-base/src/ScrollArea/constants.ts
@@ -0,0 +1,2 @@
+export const SCROLL_TIMEOUT = 500;
+export const MIN_THUMB_SIZE = 16;
diff --git a/packages/mui-base/src/ScrollArea/index.parts.ts b/packages/mui-base/src/ScrollArea/index.parts.ts
new file mode 100644
index 0000000000..a6193a16bd
--- /dev/null
+++ b/packages/mui-base/src/ScrollArea/index.parts.ts
@@ -0,0 +1,5 @@
+export { ScrollAreaRoot as Root } from './Root/ScrollAreaRoot';
+export { ScrollAreaViewport as Viewport } from './Viewport/ScrollAreaViewport';
+export { ScrollAreaScrollbar as Scrollbar } from './Scrollbar/ScrollAreaScrollbar';
+export { ScrollAreaThumb as Thumb } from './Thumb/ScrollAreaThumb';
+export { ScrollAreaCorner as Corner } from './Corner/ScrollAreaCorner';
diff --git a/packages/mui-base/src/ScrollArea/index.ts b/packages/mui-base/src/ScrollArea/index.ts
new file mode 100644
index 0000000000..0b92c3a71f
--- /dev/null
+++ b/packages/mui-base/src/ScrollArea/index.ts
@@ -0,0 +1 @@
+export * as ScrollArea from './index.parts';
diff --git a/packages/mui-base/src/index.ts b/packages/mui-base/src/index.ts
index 00b01aa772..ea3ee6e602 100644
--- a/packages/mui-base/src/index.ts
+++ b/packages/mui-base/src/index.ts
@@ -13,6 +13,7 @@ export * from './Popover';
export * from './PreviewCard';
export * from './Progress';
export * from './RadioGroup';
+export * from './ScrollArea';
export * from './Separator';
export * from './Slider';
export * from './Switch';