@lattice-ui/react-scroll-areaStable directionimport ScrollAreadepends on runtimeScroll Area builds a scroll container with custom-styled scrollbars over Roblox’s native
ScrollingFrame. Viewport is the scrolling surface. Root reads its canvas metrics and decides
when scrollbars show. Scrollbar and Thumb render a draggable indicator wired to
CanvasPosition. You keep the native scroll feel with scrollbars you style yourself.
Preview
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. Viewport holds your content. Add a
Scrollbar per axis, a Thumb directly inside its track, 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 default viewport, scrollbar track and thumb. Root renders no instance, so the parts sit in a
plain container frame you provide. The scrollbar appears only once the list 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. Pass orientation="horizontal" to both Scrollbar and
Thumb, and use asChild to size the viewport and track into your layout.
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 both ways. Provide one scrollbar/thumb pair per axis plus a Corner, which
shows only while both scrollbars are visible. Each track is shortened by the other’s thickness so
they meet at the corner.
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 scrollbars appear. "auto" and "always" keep one on screen while its axis
overflows. "scroll" is transient, appearing on scroll activity and fading after
scrollHideDelayMs. In every mode, an axis with no overflow shows nothing.
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 when you need a different element class than the frames these render. The scrollbar
slot projects visibility and track-press handling onto your track. The thumb slot projects its
computed Position and Size plus drag handling onto your handle. Do not set Position or Size
on the thumb, and keep it a direct child of the track, which it measures 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 XY, and the native scrollbars hidden. It registers the frame with Root and
pushes the per-axis viewportSize, contentSize and scrollPosition into context on every change.
An equality bail-out means identical measurements never re-render. Because it is a real
ScrollingFrame, wheel, touch-drag and momentum scrolling work natively.
Scrollbar visibility
Root derives overflow per axis, with a 1px tolerance, and resolves visibility from type:
"auto"(default) — show a scrollbar while that axis overflows."always"— show whenever the axis overflows, and 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 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 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. Its offset tracks the scroll position. Both are applied as
scale values, so the same thumb works on any track size. The rendered scale assumes the track spans
the viewport’s length on that axis, but drags and track presses re-measure the track’s real
AbsoluteSize. Interaction stays exact even on an inset track.
Dragging and track presses
Pressing the thumb starts a drag. Pointer movement is tracked globally through UserInputService,
the delta is mapped from thumb offset to a new CanvasPosition, and releasing ends it. A
GuiButton thumb has its AutoButtonColor switched off on drag start so it does not flash.
Pressing the empty track jumps the canvas so the thumb centres on the pressed point. All scroll
changes flow through Root.setScrollPosition, which clamps to [0, contentSize - viewportSize] and
writes CanvasPosition. The native frame stays the source of truth.
Orientation and composition
Scrollbar and Thumb each require an orientation, and a thumb should match the scrollbar it
sits in. The default scrollbar pins to the right or bottom edge at 8px thickness, sized to the
260x160 default viewport. For any other layout, pass asChild with your own positioned track.
Provide one pair per axis, 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 parts, so wheel and touch scrolling keep working even without a Scrollbar.
Give the viewport or its container a concrete size — the default falls back to a fixed 260x160. In practice you pass asChild with a scrollingframe sized to fill its parent, so overflow and thumb ratios compute against the real layout.
The thumb finds its track by reading its parent instance, whose AbsoluteSize and AbsolutePosition drive all drag and press maths. 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. |