@lattice-ui/react-progressStable directionimport Progressdepends on runtime, motionProgress is the primitive for visualizing how far along a task is: loading bars, download meters, XP bars, and quest trackers. Root clamps the value against a maximum and derives a 0..1 ratio; Indicator animates a fill to that ratio; and Spinner provides a self-rotating element for work that has no measurable progress.
Reach for Progress when you have a bounded value you want to render as a fill, or when you only need an indeterminate busy indicator. The primitive owns the math and the motion — your component owns the colors, sizing, and surrounding layout.
Preview
The component running live in the browser — the same @rbxts/react tree Roblox renders, fully interactive.
import { Progress } from "@lattice-ui/react-progress";import { React } from "@lattice-ui/react-runtime";import { Text, useTheme } from "@lattice-ui/react-style";
function ProgressExample() { const { theme } = useTheme(); const value = 66;
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[16])} PaddingLeft={new UDim(0, theme.space[20])} PaddingRight={new UDim(0, theme.space[20])} PaddingTop={new UDim(0, theme.space[16])} />
<Progress.Spinner asChild speedDegPerSecond={240} spinning> <frame BackgroundTransparency={1} BorderSizePixel={0} Position={UDim2.fromOffset(0, 1)} Size={UDim2.fromOffset(16, 16)} > <uicorner CornerRadius={new UDim(1, 0)} /> <uistroke Color={theme.colors.accent} Thickness={2} Transparency={0.35} /> <frame AnchorPoint={new Vector2(0.5, 0.5)} BackgroundColor3={theme.colors.accent} BorderSizePixel={0} Position={UDim2.fromScale(0.5, 0.1)} Size={UDim2.fromOffset(4, 4)} > <uicorner CornerRadius={new UDim(1, 0)} /> </frame> </frame> </Progress.Spinner>
<Text BackgroundTransparency={1} Font={Enum.Font.GothamMedium} Position={UDim2.fromOffset(26, 0)} Size={UDim2.fromOffset(200, 18)} Text="Syncing library…" TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> <Text BackgroundTransparency={1} Position={UDim2.fromOffset(230, 0)} Size={UDim2.fromOffset(50, 18)} Text={`${value}%`} TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Right} />
<Progress.Root max={100} value={value}> <frame BackgroundColor3={theme.colors.surface} BorderSizePixel={0} Position={UDim2.fromOffset(0, 30)} Size={UDim2.fromOffset(280, 8)} > <uicorner CornerRadius={new UDim(0, theme.radius.full)} /> <Progress.Indicator asChild> <frame BackgroundColor3={theme.colors.accent} BorderSizePixel={0} Size={UDim2.fromScale(1, 1)}> <uicorner CornerRadius={new UDim(0, theme.radius.full)} /> </frame> </Progress.Indicator> </frame> </Progress.Root>
<Text BackgroundTransparency={1} Position={UDim2.fromOffset(0, 48)} Size={UDim2.fromOffset(280, 16)} Text="128 of 194 items · about 2 min left" TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> </frame> );}Import
import { Progress } from "@lattice-ui/react-progress";The package also exports the value helpers clampProgressValue and resolveProgressRatio, so readouts outside the primitive can reuse the exact same math (see Percent readout).
Anatomy
Root provides the value context that Indicator reads. Spinner is independent: it needs no Root and is used on its own for indeterminate states.
Progress anatomy
<Progress.Root> <Progress.Indicator /></Progress.Root>
<Progress.Spinner />| Part | Required | Responsibility |
|---|---|---|
Progress.Root | yes | Clamps the value to [0, max], derives the fill ratio, and shares it via context. Renders no instance of its own. |
Progress.Indicator | no | A clipped window whose width animates to the current ratio, containing the fill. |
Progress.Spinner | no | A standalone, self-rotating element for indeterminate work. Does not use Root. |
Examples
Determinate loading bar
The core composition: a controlled value against a max, with the default indicator inside a track frame you own. Root renders no instance — the indicator sizes itself in scale relative to your track — so the surrounding frame decides the bar’s pixels, corners, and background. Each value change animates the fill toward the new ratio instead of snapping.
import { useState } from "@rbxts/react";import { Progress } from "@lattice-ui/react-progress";
export function DownloadBar() { const [percent, setPercent] = useState(20);
return ( <frame BackgroundTransparency={1} Size={UDim2.fromOffset(280, 48)}> <uilistlayout Padding={new UDim(0, 8)} SortOrder={Enum.SortOrder.LayoutOrder} />
<frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} BorderSizePixel={0} Size={UDim2.fromOffset(280, 12)} > <uicorner CornerRadius={new UDim(0, 6)} /> <Progress.Root value={percent} max={100}> <Progress.Indicator /> </Progress.Root> </frame>
<textbutton BackgroundColor3={Color3.fromRGB(59, 66, 84)} Event={{ Activated: () => setPercent(math.min(100, percent + 10)) }} Size={UDim2.fromOffset(120, 28)} Text="Download more" TextColor3={Color3.fromRGB(240, 244, 252)} /> </frame> );}Indeterminate state
Set indeterminate when work is running but has no measurable endpoint — connecting, matchmaking, waiting on a server. The indicator stops tracking the value and renders a fixed partial fill (0.35 of the track width) that reads as “busy”. Flip the prop back off and the same indicator animates to the real ratio, so a single bar can cover both phases of a task.
import { Progress } from "@lattice-ui/react-progress";
export function MatchmakingBar(props: { searching: boolean; loadPercent: number }) { return ( <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} BorderSizePixel={0} Size={UDim2.fromOffset(280, 10)} > <uicorner CornerRadius={new UDim(0, 5)} /> <Progress.Root value={props.loadPercent} max={100} indeterminate={props.searching}> <Progress.Indicator /> </Progress.Root> </frame> );}Standalone spinner
Progress.Spinner needs no Root — drop it anywhere you want a rotating busy glyph. spinning starts and stops the rotation loop (and hides the element while stopped), and speedDegPerSecond sets how fast it turns. The default render is a 22x22 accent ring with an orbiting dot.
import { Progress } from "@lattice-ui/react-progress";
export function SaveIndicator(props: { saving: boolean }) { return ( <frame BackgroundTransparency={1} Size={UDim2.fromOffset(160, 24)}> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, 8)} VerticalAlignment={Enum.VerticalAlignment.Center} /> <Progress.Spinner spinning={props.saving} speedDegPerSecond={270} /> <textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(120, 20)} Text={props.saving ? "Saving..." : "Saved"} TextColor3={Color3.fromRGB(236, 241, 249)} TextXAlignment={Enum.TextXAlignment.Left} /> </frame> );}Percent readout
A label next to the bar should agree with the fill exactly, including clamping and the max floor. Rather than re-deriving value / max by hand, use the exported resolveProgressRatio helper — it is the same function Root uses internally, so an out-of-range value that renders as a full bar also reads as 100%.
import { Progress, resolveProgressRatio } from "@lattice-ui/react-progress";
export function LevelProgress(props: { xp: number; xpForNextLevel: number }) { const ratio = resolveProgressRatio(props.xp, props.xpForNextLevel); const percentText = `${math.floor(ratio * 100)}%`;
return ( <frame BackgroundTransparency={1} Size={UDim2.fromOffset(280, 16)}> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, 8)} VerticalAlignment={Enum.VerticalAlignment.Center} />
<frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} BorderSizePixel={0} Size={UDim2.fromOffset(232, 10)} > <uicorner CornerRadius={new UDim(0, 5)} /> <Progress.Root value={props.xp} max={props.xpForNextLevel}> <Progress.Indicator /> </Progress.Root> </frame>
<textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(40, 16)} Text={percentText} TextColor3={Color3.fromRGB(236, 241, 249)} TextXAlignment={Enum.TextXAlignment.Left} /> </frame> );}Custom fill and motion
transition overrides how the fill width settles after each value change; asChild swaps the frame the indicator renders for your own fill element. The default recipe settles in 0.12s — pass createProgressResponseRecipe from @lattice-ui/react-motion with a longer duration for a smoother glide (a good fit for health bars, where a slow drain reads better than a snap), or a shorter one for near-instant tracking.
This is one of the few transitions that still has a default in 0.7.0: the fill is geometry the primitive computes from value, so the motion that follows that value stays part of the primitive’s behavior rather than decoration.
import { Progress } from "@lattice-ui/react-progress";import { createProgressResponseRecipe } from "@lattice-ui/react-motion";
const SMOOTH_DRAIN = createProgressResponseRecipe(0.3);
export function HealthBar(props: { health: number; maxHealth: number }) { return ( <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} BorderSizePixel={0} Size={UDim2.fromOffset(220, 14)} > <uicorner CornerRadius={new UDim(0, 7)} /> <Progress.Root value={props.health} max={props.maxHealth}> <Progress.Indicator transition={SMOOTH_DRAIN} asChild> <frame BackgroundColor3={Color3.fromRGB(102, 200, 140)} BorderSizePixel={0}> <uicorner CornerRadius={new UDim(0, 7)} /> </frame> </Progress.Indicator> </Progress.Root> </frame> );}How it behaves
Value and range
Progress.Root accepts a value and a max (default 100, floored to at least 1). The value is clamped to [0, max], and the fill ratio is clampedValue / max. Root is controllable: pass value to drive it from app state, or defaultValue (default 0) to run uncontrolled. onValueChange mirrors the core controllable-state contract, though in practice progress is almost always controlled — nothing inside the primitive changes the value on its own.
Root renders no GuiObject; it only provides context (value, max, ratio, indeterminate) to its children. The track — the visible background of the bar — is whatever frame you place Root inside.
The shared math
Clamping and ratio derivation live in two exported helpers: clampProgressValue(value, max) clamps against the floored max, and resolveProgressRatio(value, max, indeterminate?) returns the 0..1 ratio (or the fixed 0.25 indeterminate ratio). Root uses these internally, so a percent label or custom readout built on the same helpers can never disagree with the rendered fill.
Indeterminate
Set indeterminate on Root to signal work without a known endpoint. The shared ratio resolves to a fixed 0.25 for anything reading context, and Progress.Indicator renders a fixed 0.35-width fill instead of tracking the value — a partial bar you can style as a busy state. The value props are still accepted and clamped while indeterminate, so switching the flag off animates the fill straight to the real ratio. Use Spinner instead when you want a rotating glyph rather than a bar.
Indicator structure and motion
Progress.Indicator renders a transparent, clipped (ClipsDescendants) window anchored to the left of your track. The window’s Size animates from UDim2.fromScale(0, 1) toward UDim2.fromScale(ratio, 1) using the default progress response recipe from @lattice-ui/react-motion — createProgressResponseRecipe(), a 0.12s swift layout settle. The fill inside always spans the window at full scale, so the visual effect is a fill growing along the track.
Pass transition (a ResponseMotionConfig) to override the recipe — rebuild it with createProgressResponseRecipe(duration) for a slower or snappier settle, or supply your own target/settle config. By default the fill is a solid Color3.fromRGB(102, 156, 255) frame and any children render inside it; with asChild, your child element becomes the fill and its Position and Size are forced to fill the window.
Spinner rotation
Progress.Spinner rotates its root GuiObject every RunService.Heartbeat, advancing Rotation by speedDegPerSecond (default 180) times the frame delta — smooth at any frame rate. When spinning is false, the connection is disconnected and the element is hidden (Visible = false); rotation is not reset, so re-enabling resumes from the last angle. The default render is a 22x22 ring (an accent uistroke on a fully rounded frame) with a small accent dot; with asChild, your child element is the one rotated and its Visible property is bound to spinning.
Progress.Root is a pure context provider, and the indicator sizes itself in scale. Always place Root inside a frame that defines the bar’s pixel size and background; without one, the indicator has nothing to measure against and nothing behind it.
A value above max renders a full bar, a negative value renders empty, and a max below 1 is floored to 1. This makes progress safe to drive from raw replication or arithmetic without guarding every update — but it also means a bug that overshoots max looks like a finished bar, so validate upstream if “over 100%” is meaningful in your data.
Progress.Spinner does not read Root context — it is a self-contained indeterminate indicator you can place anywhere, including outside any Progress.Root. It keeps rotating as long as it is mounted and spinning is true, whether or not it is on screen, so unmount long-lived spinners you no longer need rather than merely hiding them yourself.
API reference
Progress.Root
| Prop | Type | Description |
|---|---|---|
| value | number | Controlled progress value. Clamped to [0, max]. |
| defaultValue | number | Initial value for uncontrolled usage. Defaults to 0. |
| onValueChange | (value: number) => void | Called when the controllable value changes. |
| max | number | Upper bound of the range. Defaults to 100; floored to a minimum of 1. |
| indeterminate | boolean | Marks the progress as having no known endpoint. Forces the shared ratio to 0.25 and the indicator to a fixed 0.35-width fill. Defaults to false. |
| children | React.ReactNode | The indicator and any surrounding content. Root renders no instance of its own. |
Progress.Indicator
| Prop | Type | Description |
|---|---|---|
| transition | ResponseMotionConfig | Overrides the default progress response recipe (a 0.12s settle) used to animate the fill width. |
| asChild | boolean | Render your own fill element instead of the default accent frame. Its Position and Size are forced to fill the animated window. |
| children | React.ReactElement | The fill element to render. Required when asChild is set; otherwise rendered inside the default fill. |
Progress.Spinner
| Prop | Type | Description |
|---|---|---|
| spinning | boolean | Whether the spinner rotates and is visible. Stopping does not reset Rotation. Defaults to true. |
| speedDegPerSecond | number | Rotation speed in degrees per second, applied per Heartbeat frame delta. Defaults to 180. |
| asChild | boolean | Rotate your own element instead of the default ring. Its Visible property is bound to spinning. |
| children | React.ReactElement | The element to rotate. Required when asChild is set. |