Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/scroll-area-content-props.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@radix-ui/react-scroll-area': minor
---

Added `contentProps` to `ScrollArea.Viewport` for forwarding props to the internal content wrapper div, enabling layout customization (e.g. overriding the default `display: table`) without `!important`.
45 changes: 45 additions & 0 deletions apps/storybook/stories/scroll-area.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,51 @@ export const Animated = () => {
);
};

export const ContentProps = () => {
return (
<div className={styles.root}>
<h2>Default (display: table)</h2>
<ScrollAreaStory type="always" style={{ width: 400, height: 300 }}>
{Array.from({ length: 20 }).map((_, index) => (
<div
key={index}
style={{ padding: '8px 12px', borderBottom: '1px solid #eee', whiteSpace: 'nowrap' }}
>
Item {index + 1} - This is a long text that should be constrained by the container width
</div>
))}
</ScrollAreaStory>

<h2>With contentProps (display: flex)</h2>
<ScrollArea.Root
type="always"
className={styles.scrollArea}
style={{ width: 400, height: 300 }}
>
<ScrollArea.Viewport
className={styles.scrollAreaViewport}
contentProps={{
style: { display: 'flex', flexDirection: 'column' },
}}
>
{Array.from({ length: 20 }).map((_, index) => (
<div
key={index}
style={{ padding: '8px 12px', borderBottom: '1px solid #eee' }}
>
Item {index + 1} - This is a long text that should be constrained by the container
width
</div>
))}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar className={styles.scrollbar} orientation="vertical">
<ScrollArea.Thumb className={styles.thumb} />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</div>
);
};

export const Chromatic = () => (
<div className={styles.root}>
<h1>Vertical</h1>
Expand Down
103 changes: 103 additions & 0 deletions packages/react/scroll-area/src/scroll-area.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import * as React from 'react';
import { cleanup, render } from '@testing-library/react';
import { afterEach, describe, it, expect } from 'vitest';
import * as ScrollArea from './scroll-area';

const VIEWPORT_TEST_ID = 'scroll-area-viewport';
const CONTENT_TEXT = 'Scroll content';

const ScrollAreaTest = (props: {
contentProps?: React.HTMLAttributes<HTMLDivElement>;
}) => (
<ScrollArea.Root type="always" style={{ width: 200, height: 200 }}>
<ScrollArea.Viewport data-testid={VIEWPORT_TEST_ID} contentProps={props.contentProps}>
<div>{CONTENT_TEXT}</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical">
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
);

describe('ScrollArea', () => {
afterEach(cleanup);

describe('given a default ScrollArea', () => {
it('should render content inside the viewport', () => {
const rendered = render(<ScrollAreaTest />);
expect(rendered.getByText(CONTENT_TEXT)).toBeInTheDocument();
});

it('should render the internal content wrapper with default display: table', () => {
const rendered = render(<ScrollAreaTest />);
const viewport = rendered.getByTestId(VIEWPORT_TEST_ID);
const contentWrapper = viewport.firstElementChild as HTMLElement;
expect(contentWrapper).toBeTruthy();
expect(contentWrapper.style.display).toBe('table');
expect(contentWrapper.style.minWidth).toBe('100%');
});
});

describe('given contentProps', () => {
it('should forward style overrides to the content wrapper', () => {
const rendered = render(
<ScrollAreaTest
contentProps={{ style: { display: 'flex', flexDirection: 'column' } }}
/>
);
const viewport = rendered.getByTestId(VIEWPORT_TEST_ID);
const contentWrapper = viewport.firstElementChild as HTMLElement;
expect(contentWrapper.style.display).toBe('flex');
expect(contentWrapper.style.flexDirection).toBe('column');
// minWidth should still be present as a base style
expect(contentWrapper.style.minWidth).toBe('100%');
});

it('should forward className to the content wrapper', () => {
const rendered = render(
<ScrollAreaTest contentProps={{ className: 'custom-content' }} />
);
const viewport = rendered.getByTestId(VIEWPORT_TEST_ID);
const contentWrapper = viewport.firstElementChild as HTMLElement;
expect(contentWrapper.classList.contains('custom-content')).toBe(true);
});

it('should forward data attributes to the content wrapper', () => {
const rendered = render(
<ScrollAreaTest contentProps={{ 'data-slot': 'content' } as any} />
);
const viewport = rendered.getByTestId(VIEWPORT_TEST_ID);
const contentWrapper = viewport.firstElementChild as HTMLElement;
expect(contentWrapper.getAttribute('data-slot')).toBe('content');
});

it('should allow overriding only display while preserving minWidth', () => {
const rendered = render(
<ScrollAreaTest contentProps={{ style: { display: 'block' } }} />
);
const viewport = rendered.getByTestId(VIEWPORT_TEST_ID);
const contentWrapper = viewport.firstElementChild as HTMLElement;
expect(contentWrapper.style.display).toBe('block');
expect(contentWrapper.style.minWidth).toBe('100%');
});

it('should allow overriding minWidth', () => {
const rendered = render(
<ScrollAreaTest contentProps={{ style: { minWidth: '0' } }} />
);
const viewport = rendered.getByTestId(VIEWPORT_TEST_ID);
const contentWrapper = viewport.firstElementChild as HTMLElement;
expect(contentWrapper.style.minWidth).toBe('0');
expect(contentWrapper.style.display).toBe('table');
});

it('should still render children correctly when contentProps is provided', () => {
const rendered = render(
<ScrollAreaTest
contentProps={{ style: { display: 'flex', flexDirection: 'column' } }}
/>
);
expect(rendered.getByText(CONTENT_TEXT)).toBeInTheDocument();
});
});
});
18 changes: 16 additions & 2 deletions packages/react/scroll-area/src/scroll-area.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,21 @@ const VIEWPORT_NAME = 'ScrollAreaViewport';
type ScrollAreaViewportElement = React.ComponentRef<typeof Primitive.div>;
interface ScrollAreaViewportProps extends PrimitiveDivProps {
nonce?: string;
/**
* Optional props to pass to the inner content wrapper `div`.
* Useful for overriding the default `display: table` layout when it conflicts
* with your content's sizing needs (e.g. flex or grid layouts).
*
* Note: The content wrapper uses `display: table` by default to ensure accurate
* scroll width/height measurement for thumb sizing. Overriding `display` may
* affect thumb size accuracy for horizontally-scrolling content.
*/
contentProps?: React.HTMLAttributes<HTMLDivElement>;
}

const ScrollAreaViewport = React.forwardRef<ScrollAreaViewportElement, ScrollAreaViewportProps>(
(props: ScopedProps<ScrollAreaViewportProps>, forwardedRef) => {
const { __scopeScrollArea, children, nonce, ...viewportProps } = props;
const { __scopeScrollArea, children, nonce, contentProps, ...viewportProps } = props;
const context = useScrollAreaContext(VIEWPORT_NAME, __scopeScrollArea);
const ref = React.useRef<ScrollAreaViewportElement>(null);
const composedRefs = useComposedRefs(forwardedRef, ref, context.onViewportChange);
Expand Down Expand Up @@ -180,7 +190,11 @@ const ScrollAreaViewport = React.forwardRef<ScrollAreaViewportElement, ScrollAre
* widths that change. We'll wait to see what use-cases consumers come up with there
* before trying to resolve it.
*/}
<div ref={context.onContentChange} style={{ minWidth: '100%', display: 'table' }}>
<div
{...contentProps}
ref={context.onContentChange}
style={{ minWidth: '100%', display: 'table', ...contentProps?.style }}
>
{children}
</div>
</Primitive.div>
Expand Down