Scroller
A vertically scrollable container for content that overflows its available space.
Scroller wraps Roblox's native ScrollingFrame to display content taller than the space available to it, with an automatically sized scrolling canvas and a themed scrollbar.
Import
import { Scroller } from "@rbxts/react-clean-ui";Basic usage
Sizing
Scroller accepts the shared width and height props. When omitted, it fills the available space of its parent.
<Scroller width="100%" height="300">
...
</Scroller>Give it a fixed height whenever it sits inside a container that doesn't already constrain its own height, so there's a bounded window for content to scroll within.
minWidth, maxWidth, minHeight, and maxHeight clamp the scroller's visible window, not its scrolling canvas (see min and max size). Content taller than a clamped window still scrolls.
<Scroller width="100%" height="100%" maxHeight={400}>
...
</Scroller>Setting any of these props wraps the scroller in a transparent Frame (named Scroller unless you pass name) and puts the UISizeConstraint on that wrapper. Roblox applies a UISizeConstraint under a ScrollingFrame to its automatic canvas too, so a constraint there would cap the canvas at the window size and stop the content scrolling. The ref still points at the ScrollingFrame. As with a decorated scroller (see Theme values), a scroller with min or max props can't size itself from its content, so give it an explicit or parent-relative size.
Spacing
The spacing prop reserves room for the scrollbar so it doesn't overlap the content. When the scrollbar is visible, the content area is inset by the scrollbar's thickness plus this spacing value.
<Scroller height="200" spacing="sm">
...
</Scroller>Automatic size parent
Roblox doesn't recalculate a parent's AutomaticSize correctly when its only child is a ScrollingFrame. Set AutomaticSizeParent to work around this by rendering an internal UIListLayout inside the scroller.
<Scroller AutomaticSizeParent>
...
</Scroller>Stable gutter
By default the content area only narrows to make room for the scrollbar once content actually overflows, so content shifts sideways the moment the scrollbar appears. Set stableGutter to always reserve the scrollbar's gutter (like CSS scrollbar-gutter: stable), so the content has the same width whether or not it overflows. The third scroller in the Theme values demo shows this.
<Scroller height="200" spacing="sm" stableGutter>
...
</Scroller>Scroll events
onScroll is called every time the scroller's CanvasPosition changes, whether the user scrolled or your code moved it. It receives the new canvas position and the underlying ScrollingFrame.
<Scroller
height="200"
onScroll={(canvasPosition, scroller) => {
print(`Scrolled to ${canvasPosition.Y} of ${scroller.AbsoluteCanvasSize.Y}`);
}}
>
...
</Scroller>Near-end callback
onNearEnd fires when the bottom of the visible window comes within nearEndThreshold pixels of the end of the content (default 0, meaning the very end). Use it to load the next page of an infinite or cursor-paged list.
import React from "@rbxts/react";
import { Scroller, Text, VStack } from "@rbxts/react-clean-ui";
export function InfiniteList() {
const [items, setItems] = React.useState<string[]>([]);
const [loading, setLoading] = React.useState(false);
const [hasMore, setHasMore] = React.useState(true);
const loadMore = () => {
if (loading || !hasMore) return;
setLoading(true);
task.spawn(() => {
const page = fetchNextPage();
setItems((previous) => [...previous, ...page.items]);
setHasMore(page.hasMore);
setLoading(false);
});
};
return (
<Scroller width="100%" height="300" nearEndThreshold={100} onNearEnd={loadMore}>
<VStack spacing="sm">
{items.map((item) => (
<Text key={item} text={item} />
))}
{loading && <Text text="Loading..." />}
</VStack>
</Scroller>
);
}onNearEnd fires once per approach. After firing, it stays quiet until it is re-armed, either because the canvas grew (for example, a new page was appended) or because the user scrolled more than nearEndThreshold away from the end. If the canvas grows but the end is still within the threshold, because the new page didn't fill the window, it fires again.
Content that doesn't overflow counts as being at the end, so an empty or short list fires onNearEnd on mount. Guard your handler against loads that are already in flight, as loading does above.
Ref
Scroller forwards its ref to the underlying ScrollingFrame, so you can read or set properties such as CanvasPosition directly. ScrollerProps is exported for wrapping components.
import React from "@rbxts/react";
import { Button, Scroller, VStack } from "@rbxts/react-clean-ui";
export function ScrollToTop() {
const scrollerRef = React.useRef<ScrollingFrame>();
return (
<VStack spacing="sm">
<Scroller ref={scrollerRef} width="100%" height="200">
...
</Scroller>
<Button
text="Back to top"
Event={{
Activated: () => {
if (scrollerRef.current) scrollerRef.current.CanvasPosition = Vector2.zero;
},
}}
/>
</VStack>
);
}The ref always points at the ScrollingFrame, including when the scroller is wrapped in a Frame because the theme draws a custom track or thumb (see Theme values) or a min or max size prop is set (see Sizing).
Props
Scroller-specific props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | undefined | Content rendered inside the scrolling area. |
AutomaticSizeParent | boolean | false | Adds an internal UIListLayout so an ancestor using AutomaticSize can size around the scroller. |
stableGutter | boolean | false | Always reserves the scrollbar's gutter, so content doesn't shift sideways when the scrollbar appears. |
onScroll | (canvasPosition: Vector2, scroller: ScrollingFrame) => void | undefined | Called every time CanvasPosition changes, from user input or from code. |
onNearEnd | () => void | undefined | Called once each time the end of the content comes within nearEndThreshold pixels of the window's bottom edge. |
nearEndThreshold | number | 0 | Distance in pixels from the end of the content at which onNearEnd fires. Negative values are treated as 0. |
ref | React.Ref<ScrollingFrame> | undefined | Forwarded to the underlying ScrollingFrame. |
Shared props
The scroller also supports props inherited from the following interfaces.
| Interface | Purpose |
|---|---|
SizeElementProps | Configures the scroller's width, height, and native Size, plus minWidth/maxWidth/minHeight/maxHeight to clamp its window (on a wrapper Frame, see Sizing). |
SpacedElementProps | Reserves space between the content and the scrollbar. |
PositionElementProps | Configures the scroller's position, anchor point, and centering. |
Behaviour
- Scrolling is vertical only (
ScrollingDirection.Y); content wider than the scroller is not scrollable horizontally. - The scrolling canvas grows automatically to fit its content (
AutomaticCanvasSize.Y); it does not need a fixed content height. - Without an explicit
width/height, the scroller fills 100% of its parent's size. - With none of
minWidth,maxWidth,minHeight, ormaxHeightset and a plain theme, the root is theScrollingFrameitself. Setting any of them, or a decorated theme, makes the root a transparentFrame(namedScrollerunless you passname) that wraps aScrollingFramenamedScrollingFrame, and the size constraint goes on that wrapper, never on theScrollingFrame. - The scrollbar is only rendered when the content's height exceeds the scroller's visible window; it disappears automatically once content shrinks below that threshold.
- While the scrollbar is visible, the content area is narrowed by the scrollbar's thickness plus
spacing. WithstableGutter, it is always narrowed, whether or not content overflows. onScroll,onNearEnd, andnearEndThresholdcan change between renders without reconnecting any signals.- An empty or non-overflowing list fires
onNearEndon mount, because its end is already in view. - The scrollbar is 12 pixels thick and inset within the scrolling frame.
- The scroller's own background is fully transparent and has no border.
Theme values
The scroller uses values from theme.components.scroller for:
barColor— the scrollbar's colour (ScrollBarImageColor3), and the fill of the custom thumb when one is drawntrackColor(optional) — draws a 12-pixel track behind the scrollbar while content overflowstrackTransparency(optional, default0) — the track's transparency. It has no effect unlesstrackColoris set.thumbBorderThickness(optional) — when greater than0, outlines the scrollbar thumb with a border of this thicknessthumbBorderColor(optional) — the thumb border's colour. When unset, the border is black.
The demo below shows a plain scroller, one with a track and thumb border layered over the active theme, and a stableGutter scroller whose content doesn't overflow but still leaves room for the scrollbar.
No shipped theme sets the optional keys, so by default the scroller renders Roblox's plain native scrollbar.
Roblox's native scrollbar has no track, and its thumb can't take a border. Setting trackColor or a positive thumbBorderThickness therefore changes how the scroller is built: it is wrapped in a transparent Frame that draws the track and thumb as overlays, and the native thumb is hidden underneath (it still handles dragging). Setting a min or max size prop uses the same wrapper (see Sizing), but only draws the track and thumb when the theme asks for them. This has a few consequences:
- A wrapped scroller can't size itself from its content, so
AutomaticSizeandAutomaticSizeParentwon't size it. Give it an explicit or parent-relative size. - Switching between a wrapped and an unwrapped scroller at runtime, by changing theme or by adding or removing every min and max prop, remounts the
ScrollingFrame, which resets its scroll position. - The drawn thumb's size and position approximate Roblox's own. On very long content, it may drift slightly from the invisible native thumb you actually drag.