@lattice-ui/react-scroll-areaStable directionimport ScrollAreadepends on runtimeScroll Area is the primitive for building a scroll container with custom-styled scrollbars on top of Roblox’s native ScrollingFrame. Viewport is the scrolling surface; Root reads its canvas metrics and decides when scrollbars should show; and Scrollbar/Thumb render a draggable indicator wired to the viewport’s CanvasPosition. Corner fills the gap where both axes overlap.
Reach for Scroll Area when you want the native scroll feel of a ScrollingFrame — wheel, touch drag, momentum — but with scrollbars you style yourself and overflow-aware visibility that hides them when there is nothing to scroll.
Preview
The component running live in the browser — the same @rbxts/react tree Roblox renders, fully interactive.
import { React } from "@lattice-ui/react-runtime";import { ScrollArea } from "@lattice-ui/react-scroll-area";import { Text, useTheme } from "@lattice-ui/react-style";
function ScrollAreaExample() { const { theme } = useTheme();
const tags: Array<string> = []; for (let index = 0; index < 15; index++) { tags.push(`v1.2.0-beta.${50 - index}`); }
return ( <frame BackgroundColor3={theme.colors.surfaceElevated} BorderSizePixel={0} Size={UDim2.fromScale(1, 1)}> <uicorner CornerRadius={new UDim(0, theme.radius.lg)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <uipadding PaddingBottom={new UDim(0, theme.space[8])} PaddingLeft={new UDim(0, theme.space[16])} PaddingRight={new UDim(0, theme.space[16])} PaddingTop={new UDim(0, theme.space[16])} />
<Text BackgroundTransparency={1} Font={Enum.Font.GothamBold} Size={UDim2.fromOffset(160, 18)} Text="Tags" TextColor3={theme.colors.textPrimary} TextSize={theme.typography.bodyMd.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> <Text BackgroundTransparency={1} Position={UDim2.fromOffset(158, 0)} Size={UDim2.fromOffset(50, 18)} Text="48" TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Right} />
<ScrollArea.Root> <frame BackgroundTransparency={1} Position={UDim2.fromOffset(0, 30)} Size={UDim2.fromOffset(208, 210)}> <ScrollArea.Viewport asChild> <scrollingframe AutomaticCanvasSize={Enum.AutomaticSize.None} BackgroundTransparency={1} BorderSizePixel={0} CanvasSize={UDim2.fromOffset(0, 435)} ScrollBarImageTransparency={1} ScrollBarThickness={0} ScrollingDirection={Enum.ScrollingDirection.Y} Size={UDim2.fromOffset(194, 210)} > <uilistlayout FillDirection={Enum.FillDirection.Vertical} /> {tags.map((tag, index) => ( <frame BackgroundTransparency={1} key={tag} LayoutOrder={index} Size={UDim2.fromOffset(194, 29)}> <Text BackgroundTransparency={1} Size={UDim2.fromOffset(194, 28)} Text={tag} TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> {index < tags.size() - 1 ? ( <frame BackgroundColor3={theme.colors.border} BackgroundTransparency={0.6} BorderSizePixel={0} Position={UDim2.fromOffset(0, 28)} Size={UDim2.fromOffset(186, 1)} /> ) : undefined} </frame> ))} </scrollingframe> </ScrollArea.Viewport>
<ScrollArea.Scrollbar asChild orientation="vertical"> <frame BackgroundTransparency={1} BorderSizePixel={0} Position={UDim2.fromOffset(202, 0)} Size={UDim2.fromOffset(6, 210)} > <ScrollArea.Thumb asChild orientation="vertical"> <frame BackgroundColor3={theme.colors.border} BorderSizePixel={0} Size={UDim2.fromScale(1, 1)}> <uicorner CornerRadius={new UDim(1, 0)} /> </frame> </ScrollArea.Thumb> </frame> </ScrollArea.Scrollbar> </frame> </ScrollArea.Root> </frame> );}Import
import { ScrollArea } from "@lattice-ui/react-scroll-area";Anatomy
Root provides the metrics context and renders no instance of its own. Viewport holds your content. Add a Scrollbar (with a Thumb directly inside its track) per axis, and a Corner when you show both.
ScrollArea anatomy
<ScrollArea.Root> <ScrollArea.Viewport>{/* content */}</ScrollArea.Viewport> <ScrollArea.Scrollbar orientation="vertical"> <ScrollArea.Thumb orientation="vertical" /> </ScrollArea.Scrollbar> <ScrollArea.Corner /></ScrollArea.Root>| Part | Required | Responsibility |
|---|---|---|
ScrollArea.Root | yes | Holds the viewport ref, tracks per-axis metrics, and computes scrollbar visibility from overflow and type. Renders only context, no instance. |
ScrollArea.Viewport | yes | The ScrollingFrame that scrolls content and reports its canvas/window sizes back to Root. |
ScrollArea.Scrollbar | no | A track for one axis; a press on empty track jumps the canvas so the thumb centers on the press. |
ScrollArea.Thumb | no | The draggable handle inside a scrollbar; sized and positioned to the scroll ratio. |
ScrollArea.Corner | no | Fills the intersection square, shown only when both scrollbars are visible. |
Examples
Basic vertical list
The smallest useful composition: the default viewport (a 260x160 scrollingframe), the default scrollbar track (8px wide, pinned to the right), and the default rounded thumb. Because Root renders no instance, the parts sit in a plain container frame you provide. Wheel and touch scrolling work immediately; the scrollbar appears only once the list actually overflows.
import { ScrollArea } from "@lattice-ui/react-scroll-area";
export function PatchNotes(props: { lines: string[] }) { return ( <ScrollArea.Root> <frame BackgroundColor3={Color3.fromRGB(18, 20, 26)} BorderSizePixel={0} Size={UDim2.fromOffset(268, 160)}> <ScrollArea.Viewport> <uilistlayout Padding={new UDim(0, 6)} SortOrder={Enum.SortOrder.LayoutOrder} /> {props.lines.map((line, index) => ( <textlabel key={index} AutomaticSize={Enum.AutomaticSize.Y} BackgroundTransparency={1} Size={UDim2.new(1, 0, 0, 0)} Text={line} TextColor3={Color3.fromRGB(224, 230, 240)} TextWrapped /> ))} </ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical"> <ScrollArea.Thumb orientation="vertical" /> </ScrollArea.Scrollbar> </frame> </ScrollArea.Root> );}Horizontal scroll row
A single row that overflows sideways — hotbars, category chips, cosmetic carousels. Pass orientation="horizontal" to both Scrollbar and Thumb, and use asChild on the viewport and scrollbar to size them into your layout: the viewport fills the container minus the track height, and the track pins to the bottom edge.
import { ScrollArea } from "@lattice-ui/react-scroll-area";
export function CosmeticRow(props: { cosmetics: string[] }) { return ( <ScrollArea.Root> <frame BackgroundColor3={Color3.fromRGB(18, 20, 26)} BorderSizePixel={0} Size={UDim2.fromOffset(320, 84)}> <ScrollArea.Viewport asChild> <scrollingframe BackgroundTransparency={1} Size={UDim2.new(1, 0, 1, -10)}> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, 8)} SortOrder={Enum.SortOrder.LayoutOrder} VerticalAlignment={Enum.VerticalAlignment.Center} /> {props.cosmetics.map((cosmetic) => ( <textbutton key={cosmetic} BackgroundColor3={Color3.fromRGB(32, 37, 48)} Size={UDim2.fromOffset(64, 64)} Text={cosmetic} TextColor3={Color3.fromRGB(224, 230, 240)} > <uicorner CornerRadius={new UDim(0, 8)} /> </textbutton> ))} </scrollingframe> </ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="horizontal" asChild> <frame AnchorPoint={new Vector2(0, 1)} BackgroundColor3={Color3.fromRGB(40, 46, 60)} BorderSizePixel={0} Position={UDim2.fromScale(0, 1)} Size={UDim2.new(1, 0, 0, 6)} > <ScrollArea.Thumb orientation="horizontal" /> </frame> </ScrollArea.Scrollbar> </frame> </ScrollArea.Root> );}Both axes with a corner
Content that overflows in both directions — maps, skill trees, wide tables. Provide one scrollbar/thumb pair per axis and a Corner for the square where the two tracks would overlap; the corner shows itself only while both scrollbars are visible. Each track is shortened by the other track’s thickness so they meet at the corner instead of crossing.
import { ScrollArea } from "@lattice-ui/react-scroll-area";
export function WorldMap() { return ( <ScrollArea.Root> <frame BackgroundColor3={Color3.fromRGB(18, 20, 26)} BorderSizePixel={0} Size={UDim2.fromOffset(320, 240)}> <ScrollArea.Viewport asChild> <scrollingframe BackgroundTransparency={1} Size={UDim2.new(1, -8, 1, -8)}> <imagelabel BackgroundTransparency={1} Image="rbxassetid://1234567890" Size={UDim2.fromOffset(800, 600)} /> </scrollingframe> </ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" asChild> <frame AnchorPoint={new Vector2(1, 0)} BackgroundColor3={Color3.fromRGB(40, 46, 60)} BorderSizePixel={0} Position={UDim2.fromScale(1, 0)} Size={UDim2.new(0, 8, 1, -8)} > <ScrollArea.Thumb orientation="vertical" /> </frame> </ScrollArea.Scrollbar>
<ScrollArea.Scrollbar orientation="horizontal" asChild> <frame AnchorPoint={new Vector2(0, 1)} BackgroundColor3={Color3.fromRGB(40, 46, 60)} BorderSizePixel={0} Position={UDim2.fromScale(0, 1)} Size={UDim2.new(1, -8, 0, 8)} > <ScrollArea.Thumb orientation="horizontal" /> </frame> </ScrollArea.Scrollbar>
<ScrollArea.Corner asChild> <frame AnchorPoint={new Vector2(1, 1)} BackgroundColor3={Color3.fromRGB(40, 46, 60)} BorderSizePixel={0} Position={UDim2.fromScale(1, 1)} Size={UDim2.fromOffset(8, 8)} /> </ScrollArea.Corner> </frame> </ScrollArea.Root> );}Scrollbar visibility modes
type controls when visible scrollbars appear. "auto" (the default) and "always" show a scrollbar while its axis overflows and keep it on screen. "scroll" is the transient mode: the bar appears on scroll activity and fades out after scrollHideDelayMs of inactivity — a good fit for chat logs and feeds where a permanent track is visual noise. In every mode, an axis with no overflow shows no scrollbar at all.
import { ScrollArea } from "@lattice-ui/react-scroll-area";
export function ChatLog(props: { messages: string[] }) { return ( <ScrollArea.Root type="scroll" scrollHideDelayMs={1200}> <frame BackgroundColor3={Color3.fromRGB(18, 20, 26)} BorderSizePixel={0} Size={UDim2.fromOffset(300, 180)}> <ScrollArea.Viewport asChild> <scrollingframe BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)}> <uilistlayout Padding={new UDim(0, 4)} SortOrder={Enum.SortOrder.LayoutOrder} /> {props.messages.map((message, index) => ( <textlabel key={index} AutomaticSize={Enum.AutomaticSize.Y} BackgroundTransparency={1} Size={UDim2.new(1, -12, 0, 0)} Text={message} TextColor3={Color3.fromRGB(224, 230, 240)} TextWrapped TextXAlignment={Enum.TextXAlignment.Left} /> ))} </scrollingframe> </ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" asChild> <frame AnchorPoint={new Vector2(1, 0)} BackgroundTransparency={1} Position={UDim2.fromScale(1, 0)} Size={UDim2.new(0, 6, 1, 0)} > <ScrollArea.Thumb orientation="vertical" /> </frame> </ScrollArea.Scrollbar> </frame> </ScrollArea.Root> );}Custom track and thumb with asChild
Use asChild on Scrollbar and Thumb when you need a different element class than the frames they render. The scrollbar’s Slot projects visibility and track-press handling onto your track element; the thumb’s Slot projects its computed Position and Size (both scale-based) plus drag handling onto your handle. Do not set Position or Size on the thumb element — the primitive owns them — and style it with corners, strokes, and colors instead. Keep the Thumb a direct child of the track element: the thumb measures its parent to map drags to canvas positions.
import { ScrollArea } from "@lattice-ui/react-scroll-area";
export function QuestLog(props: { quests: string[] }) { return ( <ScrollArea.Root> <frame BackgroundColor3={Color3.fromRGB(22, 25, 33)} BorderSizePixel={0} Size={UDim2.fromOffset(280, 200)}> <uicorner CornerRadius={new UDim(0, 10)} />
<ScrollArea.Viewport asChild> <scrollingframe BackgroundTransparency={1} Size={UDim2.new(1, -14, 1, 0)}> <uilistlayout Padding={new UDim(0, 6)} SortOrder={Enum.SortOrder.LayoutOrder} /> <uipadding PaddingLeft={new UDim(0, 10)} PaddingTop={new UDim(0, 10)} /> {props.quests.map((quest) => ( <textlabel key={quest} AutomaticSize={Enum.AutomaticSize.Y} BackgroundTransparency={1} Size={UDim2.new(1, 0, 0, 0)} Text={quest} TextColor3={Color3.fromRGB(224, 230, 240)} TextWrapped TextXAlignment={Enum.TextXAlignment.Left} /> ))} </scrollingframe> </ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" asChild> <frame AnchorPoint={new Vector2(1, 0)} BackgroundColor3={Color3.fromRGB(32, 37, 48)} BorderSizePixel={0} Position={UDim2.new(1, -4, 0, 8)} Size={UDim2.new(0, 6, 1, -16)} > <uicorner CornerRadius={new UDim(1, 0)} />
<ScrollArea.Thumb orientation="vertical" asChild> <frame BackgroundColor3={Color3.fromRGB(88, 142, 255)} BorderSizePixel={0}> <uicorner CornerRadius={new UDim(1, 0)} /> <uistroke Color={Color3.fromRGB(130, 170, 255)} Thickness={1} Transparency={0.6} /> </frame> </ScrollArea.Thumb> </frame> </ScrollArea.Scrollbar> </frame> </ScrollArea.Root> );}How it behaves
Scrolling and metrics
ScrollArea.Viewport renders a ScrollingFrame with AutomaticCanvasSize on both axes, ScrollingDirection set to XY, and the native scrollbars hidden (ScrollBarThickness = 0, fully transparent image). It registers the frame with Root and listens to CanvasPosition, AbsoluteCanvasSize, and AbsoluteWindowSize changes, pushing the per-axis viewportSize, contentSize, and scrollPosition into context on every change (with an equality bail-out so identical measurements never re-render). Because the underlying control is a real ScrollingFrame, mouse-wheel, touch-drag, and momentum scrolling all work natively — the primitive layers custom scrollbars on top of that. The default viewport is a fixed 260x160 frame; use asChild to supply your own scrollingframe sized to your layout.
Scrollbar visibility
Root derives overflow per axis — content larger than the viewport, with a 1px tolerance — and resolves visibility from type:
"auto"(default) — show a scrollbar while that axis overflows."always"— show whenever the axis overflows; never auto-hide."scroll"— show on scroll activity, then auto-hide afterscrollHideDelayMs(default600ms, floored at 0) of inactivity, only while overflowing.
For type="scroll", any CanvasPosition change — native scrolling, a thumb drag, or a track press — counts as activity and restarts the hide timer. Scrollbar and Corner read the resolved flags and toggle their own Visible (and Active, so a hidden track ignores input). Corner shows only when both the vertical and horizontal scrollbars are visible.
Thumb sizing and position
ScrollArea.Thumb computes its length as the viewport-to-content ratio of the track, clamped to a minimum of 18px so it stays grabbable against very long content, and its offset tracks the current scroll position. Both are applied as scale values — full track thickness across, viewport/content of the track along the axis — so the same thumb works on any track size. The rendered scale assumes the track spans the viewport’s length on that axis; drags and track presses always re-measure the track’s real AbsoluteSize, so interaction stays exact even when your track is inset like the QuestLog example above.
Dragging and track presses
Pressing the thumb (mouse button or touch) starts a drag: pointer movement is tracked globally through UserInputService, the delta is mapped from thumb offset to a new CanvasPosition, and releasing the pointer ends the drag. If your thumb is a GuiButton, its AutoButtonColor is switched off on drag start so it does not flash while dragging. Pressing the empty Scrollbar track (outside the thumb) jumps the canvas so the thumb centers on the pressed point; presses on the thumb region are left to the thumb’s own drag handling. All scroll changes flow through Root.setScrollPosition, which clamps to [0, contentSize - viewportSize] and writes the viewport’s CanvasPosition — the native frame stays the single source of truth.
Orientation and composition
Scrollbar and Thumb each require an orientation of "vertical" or "horizontal", and a thumb should match the scrollbar it sits in. The default rendered scrollbar pins to the right edge (vertical) or bottom edge (horizontal) at an 8px thickness sized to the 260x160 default viewport; for any other layout, pass asChild with your own positioned track. Provide one scrollbar/thumb pair per axis you want to expose, and add a Corner when both are present.
The viewport is a native ScrollingFrame and remains the source of truth for CanvasPosition. Lattice-UI only hides the built-in scrollbars and reflects the canvas metrics into your custom parts — it never replaces native scroll input, so wheel and touch scrolling keep working even without a Scrollbar.
Give the viewport (or its container) a concrete size. The default Viewport falls back to a fixed 260x160 offset size; in practice you will pass asChild with a scrollingframe sized to fill its parent, as in the examples, so overflow and thumb ratios compute against the real layout.
The thumb finds its track by reading its parent instance — that parent’s AbsoluteSize and AbsolutePosition drive all drag and press math. Whether you use the default scrollbar or asChild, render Thumb as a direct child of the track element, with no wrapper frames in between.
API reference
ScrollArea.Root
| Prop | Type | Description |
|---|---|---|
| type | "auto" | "always" | "scroll" | Scrollbar visibility strategy. Defaults to "auto". |
| scrollHideDelayMs | number | For type="scroll", how long after activity before scrollbars auto-hide. Defaults to 600; floored at 0. |
| children | React.ReactNode | Viewport, scrollbars, thumbs, and corner. Root renders no instance of its own. |
ScrollArea.Viewport
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Render your own scrollingframe instead of the default 260x160 one; the scroll props and metrics ref are projected onto it. |
| children | React.ReactElement | With asChild, the scrollingframe element; otherwise the content placed inside the default viewport. |
ScrollArea.Scrollbar
| Prop | Type | Description |
|---|---|---|
| orientation | "vertical" | "horizontal" | Which axis this scrollbar controls. Required. |
| asChild | boolean | Render your own track element via Slot; visibility and track-press handling are projected onto it. |
| children | React.ReactElement | With asChild, the track element; otherwise content placed inside the default track, typically a Thumb. |
ScrollArea.Thumb
| Prop | Type | Description |
|---|---|---|
| orientation | "vertical" | "horizontal" | Which axis this thumb belongs to. Required; should match its scrollbar. |
| asChild | boolean | Render your own thumb element via Slot; the computed Position, Size, and drag handling are projected onto it, overriding your own. |
| children | React.ReactElement | With asChild, the thumb element; otherwise content placed inside the default thumb. |
ScrollArea.Corner
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Render your own corner element via Slot; its visibility is bound to both scrollbars being shown. |
| children | React.ReactElement | With asChild, the corner element; otherwise content placed inside the default corner. |