Latticecomponents

Scroll Area

A Roblox `ScrollingFrame` with custom scrollbars and overflow-aware visibility.

@lattice-ui/react-scroll-areaStable directionimport ScrollAreadepends on runtime

Scroll 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

Edit

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 anatomy
<ScrollArea.Root>
<ScrollArea.Viewport>{/* content */}</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical">
<ScrollArea.Thumb orientation="vertical" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner />
</ScrollArea.Root>
PartRequiredResponsibility
ScrollArea.RootyesHolds the viewport ref, tracks per-axis metrics, and computes scrollbar visibility from overflow and type. Renders only context, no instance.
ScrollArea.ViewportyesThe ScrollingFrame that scrolls content and reports its canvas/window sizes back to Root.
ScrollArea.ScrollbarnoA track for one axis; a press on empty track jumps the canvas so the thumb centers on the press.
ScrollArea.ThumbnoThe draggable handle inside a scrollbar; sized and positioned to the scroll ratio.
ScrollArea.CornernoFills 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.

PatchNotes.tsx
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.

CosmeticRow.tsx
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.

WorldMap.tsx
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.

ChatLog.tsx
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.

QuestLog.tsx
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 after scrollHideDelayMs (default 600ms, 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.

API reference

ScrollArea.Root

PropTypeDescription
type"auto" | "always" | "scroll"Scrollbar visibility strategy. Defaults to "auto".
scrollHideDelayMsnumberFor type="scroll", how long after activity before scrollbars auto-hide. Defaults to 600; floored at 0.
childrenReact.ReactNodeViewport, scrollbars, thumbs, and corner. Root renders no instance of its own.

ScrollArea.Viewport

PropTypeDescription
asChildbooleanRender your own scrollingframe instead of the default 260x160 one; the scroll props and metrics ref are projected onto it.
childrenReact.ReactElementWith asChild, the scrollingframe element; otherwise the content placed inside the default viewport.

ScrollArea.Scrollbar

PropTypeDescription
orientation"vertical" | "horizontal"Which axis this scrollbar controls. Required.
asChildbooleanRender your own track element via Slot; visibility and track-press handling are projected onto it.
childrenReact.ReactElementWith asChild, the track element; otherwise content placed inside the default track, typically a Thumb.

ScrollArea.Thumb

PropTypeDescription
orientation"vertical" | "horizontal"Which axis this thumb belongs to. Required; should match its scrollbar.
asChildbooleanRender your own thumb element via Slot; the computed Position, Size, and drag handling are projected onto it, overriding your own.
childrenReact.ReactElementWith asChild, the thumb element; otherwise content placed inside the default thumb.

ScrollArea.Corner

PropTypeDescription
asChildbooleanRender your own corner element via Slot; its visibility is bound to both scrollbars being shown.
childrenReact.ReactElementWith asChild, the corner element; otherwise content placed inside the default corner.