Latticecomponents

Progress

Progress-value primitive that owns a clamped numeric range and feeds a motion-driven indicator, plus a standalone spinner for indeterminate work.

@lattice-ui/react-progressStable directionimport Progressdepends on runtime, motion

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

Edit

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 anatomy
<Progress.Root>
<Progress.Indicator />
</Progress.Root>
<Progress.Spinner />
PartRequiredResponsibility
Progress.RootyesClamps the value to [0, max], derives the fill ratio, and shares it via context. Renders no instance of its own.
Progress.IndicatornoA clipped window whose width animates to the current ratio, containing the fill.
Progress.SpinnernoA 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.

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

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

SaveIndicator.tsx
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%.

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

HealthBar.tsx
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-motioncreateProgressResponseRecipe(), 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.

API reference

Progress.Root

PropTypeDescription
valuenumberControlled progress value. Clamped to [0, max].
defaultValuenumberInitial value for uncontrolled usage. Defaults to 0.
onValueChange(value: number) => voidCalled when the controllable value changes.
maxnumberUpper bound of the range. Defaults to 100; floored to a minimum of 1.
indeterminatebooleanMarks 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.
childrenReact.ReactNodeThe indicator and any surrounding content. Root renders no instance of its own.

Progress.Indicator

PropTypeDescription
transitionResponseMotionConfigOverrides the default progress response recipe (a 0.12s settle) used to animate the fill width.
asChildbooleanRender your own fill element instead of the default accent frame. Its Position and Size are forced to fill the animated window.
childrenReact.ReactElementThe fill element to render. Required when asChild is set; otherwise rendered inside the default fill.

Progress.Spinner

PropTypeDescription
spinningbooleanWhether the spinner rotates and is visible. Stopping does not reset Rotation. Defaults to true.
speedDegPerSecondnumberRotation speed in degrees per second, applied per Heartbeat frame delta. Defaults to 180.
asChildbooleanRotate your own element instead of the default ring. Its Visible property is bound to spinning.
childrenReact.ReactElementThe element to rotate. Required when asChild is set.