Skip to content

Scroll Area

A scroll area is a box that scrolls, with the platform's scrollbars replaced by ones you can style — and with the edges of the content fading out towards whatever is still off-screen. Reach for it when a region needs to scroll independently of the page: a list in a panel, a log, a rail of cards, a tab strip too long for its container.

Do not reach for it for the page itself. The document scrollbar is the one place where the platform's own behaviour — the OS overlay setting, scroll anchoring, find-in-page, the browser's own scroll restoration — matters more than a consistent look.

scroll-area/basic.tsx

Import

import { ScrollArea } from "@forte-ui/react";

ScrollArea is a namespace of six parts:

<ScrollArea.Root>
  <ScrollArea.Viewport>
    <ScrollArea.Content>{/* … */}</ScrollArea.Content>
  </ScrollArea.Viewport>
  <ScrollArea.Scrollbar orientation="vertical">
    <ScrollArea.Thumb />
  </ScrollArea.Scrollbar>
  <ScrollArea.Scrollbar orientation="horizontal">
    <ScrollArea.Thumb />
  </ScrollArea.Scrollbar>
  <ScrollArea.Corner />
</ScrollArea.Root>

Root is the box you give a size to. Viewport is the real scroll container — the element with overflow, the element that takes focus, and the element the fade is painted on. Content wraps everything inside it. Each Scrollbar overlays one edge and holds one Thumb; Corner fills the square where two scrollbars would otherwise cross.

Render only the parts you need. A vertically scrolling panel needs one Scrollbar and no Corner, and a scrollbar whose axis does not overflow is not rendered at all, so there is no cost to declaring both.

Examples

Vertical

The demo at the top of the page. One Scrollbar, a max-height on the root, and a fade at whichever end still has content past it — at rest at the top there is only a bottom fade, and it closes as the last item comes into view.

The scrollbars overlay the content rather than insetting it, so nothing reflows when they appear. That also means content can pass underneath them: the demo gives its Content a padding-inline-end so the text stops short of where the thumb will be.

Horizontal

Nothing changes but the axis. ScrollArea.Content is what makes it work — see Sizing below.

scroll-area/horizontal.tsx

Notice the fade switching sides as you scroll. Its direction is the inline axis, not the physical one, so in an RTL context the start fade appears on the right; the demo frame's direction toggle shows this.

One prop matters here beyond the axis swap: orientation="horizontal". Without it the viewport is a scroll container on both axes, so a vertical wheel or trackpad gesture over the row is claimed by a box that has nothing to scroll that way — the content stays put, the page behind does not scroll either, and macOS paints its rubber-band bounce on the trapped viewport. Naming the axis turns the other one off, and the gesture falls through to the page. Declare it whenever only one axis can ever overflow — the vertical demos on this page all carry orientation="vertical" for the mirrored reason.

Both scrollbars

When either axis might overflow, render both Scrollbars and the Corner. Base UI measures the tracks and shortens each one by the corner's size, so they never overlap and the corner is exactly the square they would have fought over. It mounts only while both scrollbars are visible.

scroll-area/both-axes.tsx

Both fades are live at once here, composited so that a corner fading on two edges fades once rather than twice.

Scrollable tabs

A tab strip is the case that most wants this component: the tabs are fixed-width, they cannot wrap, and there is no room to show all of them. So much so that Tabs.List now does it for you — under Tabs.Root's default overflow="scroll" the strip wraps itself in a scroll area, with the fade but deliberately without a scrollbar. This demo is for when you want the scrollbar back: overflow="visible" switches the built-in wrap off, and an explicit scroll area goes around Tabs.List — not Tabs.Root — so the strip scrolls and the panels below it stay put.

scroll-area/tabs.tsx
Deployed 4 minutes ago from main.

Three things are load-bearing in that arrangement, and all three are already handled:

  • The indicator still tracks. Base UI measures the active tab against Tabs.List, which is inside the viewport and scrolls with it, so the sliding underline stays attached to its tab at every scroll position.
  • The rail runs the full width. Tabs.List is sized to its tabs rather than to the viewport, so the line variant's rail extends under the tabs that are still off-screen instead of stopping at the fold.
  • Arrowing to an off-screen tab scrolls it into view, clear of the fade. The browser brings a newly focused tab into view on its own; the scroll-margin that every .forte-focus-ring element carries is what stops it landing right against the edge, half-faded, with its focus ring underneath the gradient.

Always-visible scrollbars, no fade

scrollbarVisibility="always" keeps the scrollbars painted instead of revealing them on hover, and fade={false} turns the mask off. Together they are the conservative setting — worth choosing when the scroll area sits in dense, data-heavy chrome where a fade would read as content being cut off rather than continuing.

scroll-area/visibility.tsx

Sizing

ScrollArea.Content exists because the two axes want opposite things from a box, and it is the one part worth understanding before you use the component.

A plain block child of a scroll container is stretched to the width of the scrollport. That is right for a paragraph — it wraps, and the area scrolls vertically — and wrong for a row of cards, which gets clipped at the fold instead of scrolling, taking any background or rail on the row with it. Sizing the child to max-content fixes the row and breaks the paragraph, which now refuses to wrap and gains a horizontal scrollbar it never needed.

ScrollArea.Content is a table box, so its width is max(min-content, min(max-content, available)). Content that cannot shrink — a flex row of tabs, a wide <pre> — has a min-content width larger than the scrollport, so the box grows past it and scrolls horizontally. Content that can shrink lands on the available width and wraps exactly as a block would. One element, both answers, no prop to get wrong.

It is optional for a purely vertical scroll area, and harmless there. Put your own layout — the grid, the flex row, the padding — on a child of it, or on Content itself as long as you leave its display alone.

Accessibility

Keyboard interactions
KeyBehaviour
TabMoves focus to the viewport, but only while it can actually scroll. When the content fits, the viewport is not a tab stop.
ArrowDown then ArrowUpScrolls the focused viewport by a line.
ArrowRight then ArrowLeftScrolls the focused viewport horizontally by a line.
PageDown then PageUpScrolls the focused viewport by a screen.
HomeScrolls to the start of the content.
EndScrolls to the end of the content.
SpaceScrolls down by a screen; with Shift, up by a screen.

Keyboard scrolling is the browser's, not ours — the viewport is a real scroll container, so every key above works because the platform makes it work, including in combination with find-in-page and with a screen reader's own reading cursor.

What the component adds is that the viewport is reachable. Base UI gives it tabIndex={0} whenever it can scroll and -1 when it cannot, which is SC 2.1.1 (Keyboard) for a region whose only interaction is scrolling: content a keyboard user cannot scroll to is content they cannot read. A scroll area full of buttons is already reachable through those buttons, and gets the stop anyway — it is where the arrow keys become useful.

The focus ring is painted on ScrollArea.Root rather than on the viewport that receives focus. That is not a stylistic choice: the fade is a mask, a mask applies to everything the element paints and is clipped to its border box, so a ring on the viewport would be cropped on all four sides and then faded out by the effect it has to remain visible through. The root is the same box, unmasked, and is where the scrollbars live, so ringing it outlines the whole scroll region — which is what focus is on. A control focused inside the content keeps its own ring and does not light up the region's.

Keyboard focus anywhere in the region also reveals the scrollbars, so a keyboard user arrowing through a list has the same position feedback a pointer user gets on hover.

Under forced-colors, under prefers-contrast: more and under prefers-reduced-transparency, the fade is dropped entirely and the scrollbars stop hiding. All three users have asked for the opposite of what a fade does — it is, by construction, a loss of contrast achieved by making text translucent — so the cue moves to the scrollbar, which is the half that survives a fixed palette. The tracks paint Canvas inside a CanvasText border and the thumbs paint CanvasText, because the UA assigns system colours from native element semantics and every part here is a div.

How the fade works

No JavaScript runs per frame, and there is no gradient overlay element anywhere in the DOM.

Base UI publishes the remaining scroll distance on each of the four edges as CSS custom properties on the viewport — --scroll-area-overflow-y-start, --scroll-area-overflow-y-end, and the two x equivalents — as px lengths, already normalised for RTL and for the browsers that report a negative scrollLeft there. The stylesheet turns them into a mask:

.viewport {
  mask-image: linear-gradient(
    to bottom,
    transparent 0,
    black min(var(--forte-scroll-area-fade-size), var(--scroll-area-overflow-y-start, 0px)),
    /* … and the mirrored pair for the end edge */
  );
  mask-composite: intersect;
}

Three consequences fall out of that shape:

  • It is a mask, not an overlay. A gradient overlay has to be painted in the colour of whatever sits behind the content, so it is wrong the moment the scroll area moves onto a card, an image or a themed surface, and it has to be re-set by hand each time. A mask fades the content itself to transparency, which is correct on every background including none.
  • The size is the scroll position. min() takes the smaller of the configured fade size and the distance still scrollable, so the fade opens out of an edge as the content moves under it and is exactly 0px while you are resting against that edge. There is deliberately no transition on it — easing a value that already is the scroll position would only make the fade lag the content it describes.
  • The horizontal fade is logical. A gradient's direction is physical, so the angle carries the sign: calc(90deg * var(--forte-direction)) is 90deg in LTR and -90deg in RTL, which makes the gradient's first stop the inline-start edge either way. Writing to right and swapping the two variables under [dir="rtl"] would break an LTR island inside an RTL page.

The two axes are separate mask layers composited with mask-composite: intersect, so a pixel shows only where both agree — a corner fading on two edges fades once rather than twice. The mask is applied only while an axis actually overflows, since it promotes the viewport to its own compositing layer and there is nothing to pay for that on a scroll area that fits its content.

Motion

There is no prefers-reduced-motion block in the stylesheet. The only motion in the component is the scrollbars fading in and out, which runs on --forte-duration-fast and collapses with it; the fade itself is not motion, and scrolling is the user's own.

Theming

Every property below is declared on ScrollArea.Root and inherited by the parts inside it, which is also why an ancestor is the wrong place to set one — the root's own declaration wins over the inherited value. Override them on ScrollArea.Root itself, through its className or an inline style. To move every scroll area at once, re-point the global tokens in the Default column instead.

Theming tokens for ScrollArea
PropertyControlsDefault
--forte-scroll-area-fade-sizeHow far the fade reaches in from an edge, at mostvar(--forte-space-5)
--forte-scroll-area-scrollbar-sizeTrack thickness on both axesvar(--forte-space-3)
--forte-scroll-area-track-paddingInset between the track's edge and its thumb2px
--forte-scroll-area-track-bgTrack filltransparent
--forte-scroll-area-track-bg-hoverTrack fill while the pointer is over itvar(--forte-color-panel)
--forte-scroll-area-thumb-bgThumb fillvar(--forte-color-border-strong)
--forte-scroll-area-thumb-bg-hoverThumb fill on hovervar(--forte-color-foreground-subtle)
--forte-scroll-area-thumb-bg-activeThumb fill while being draggedvar(--forte-color-foreground-muted)
--forte-scroll-area-thumb-radiusCorner radius of the track and thumbvar(--forte-radius-pill)
--forte-scroll-area-corner-bgFill of the square between the two scrollbarstransparent

A border-radius set on ScrollArea.Root is inherited by the viewport, which is the box that actually clips, so rounding the root rounds the scrolled content with it.

The parts also expose their state as data attributes — data-has-overflow-x, data-has-overflow-y, data-overflow-x-start, data-overflow-x-end, data-overflow-y-start, data-overflow-y-end, data-scrolling, data-hovering, data-orientation (each scrollbar's axis, and on the root and viewport the orientation prop) — so a Tailwind arbitrary variant such as data-[overflow-y-start]:... can target them without a wrapper element. The four data-overflow-* attributes are the boolean form of the same measurements the fade uses; overflowEdgeThreshold on ScrollArea.Root sets how many pixels must be scrolled before they appear.

API reference

Each part forwards every prop it does not consume to its Base UI counterpart, so the tables below list what forte-ui adds or changes the default of. ScrollArea.Root also takes overflowEdgeThreshold; ScrollArea.Scrollbar also takes keepMounted. All six parts take render for changing the underlying element.

ScrollArea.Root

Props for ScrollAreaRoot
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
fadebooleantrueWhether the content fades out towards any edge it can still be scrolled past. The fade is a mask, so it works on any background, and its size tracks the remaining scroll distance — it opens as you scroll away from an edge and is absent while you are resting against one. Dropped automatically under `forced-colors`, `prefers-contrast: more` and `prefers-reduced-transparency`, where the scrollbars carry the cue instead.
orientationScrollAreaOrientationbothThe axis — or axes — this area scrolls on. Declare it whenever only one axis can ever overflow: under `"both"` the viewport is a scroll container on both axes regardless, so a wheel or trackpad gesture along the axis with nothing to scroll is still claimed by the viewport — the content does not move, the page behind does not scroll either, and macOS paints its rubber-band bounce on a box that has nowhere to go. Naming the axis turns the other one off entirely, so that gesture falls through to the page — which is what a vertical scroll over a horizontal tab strip is almost always asking for.
scrollbarVisibilityScrollAreaScrollbarVisibilityautoWhen the scrollbars are shown. `"auto"` overlays them and reveals them while the pointer is anywhere over the scroll area, while it is being scrolled, or while something inside it has keyboard focus; `"always"` leaves them painted. Either way a scrollbar for an axis that does not overflow is not rendered at all.

ScrollArea.Viewport

Props for ScrollAreaViewport
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

ScrollArea.Content

Props for ScrollAreaContent
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

ScrollArea.Scrollbar

Props for ScrollAreaScrollbar
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
orientation"horizontal" | "vertical"verticalWhich axis this scrollbar controls. Render one for each axis that can overflow; render both, plus `ScrollArea.Corner`, when either might.

ScrollArea.Thumb

Props for ScrollAreaThumb
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

ScrollArea.Corner

Props for ScrollAreaCorner
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.