Latticecomponents

Scroll Area

Scroll-container primitive that wraps a Roblox ScrollingFrame, tracks its canvas metrics, and drives custom scrollbars and thumbs with overflow-aware visibility.

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

Scroll 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.

Edit

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

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 — 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.

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

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

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

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

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.