Latticecomponents

Progress

A clamped numeric range feeding a motion-driven indicator, plus a standalone spinner.

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

Progress visualizes how far along a task is: loading bars, download meters, XP bars, quest trackers. Root clamps the value against a maximum and derives a 0..1 ratio, and Indicator animates a fill to it. Spinner is a self-rotating element for work with no measurable progress. The primitive owns the maths and the motion. You own the colours, sizing and layout.

Preview

Edit

Import

import { Progress } from "@lattice-ui/react-progress";

It also exports clampProgressValue and resolveProgressRatio, so readouts outside the primitive reuse the same maths.

Anatomy

Root provides the value context Indicator reads. Spinner is independent — no Root needed.

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

A controlled value against a max, with the indicator inside a track frame you own. Root renders no instance and the indicator sizes itself in scale, so the surrounding frame decides the bar’s pixels, corners and background.

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 has no measurable endpoint. The indicator stops tracking the value and renders a fixed 0.35 fill. Flip it back off and the same indicator animates to the real ratio, so one bar covers both phases.

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. spinning starts and stops the loop, and hides the element while stopped. speedDegPerSecond sets the rate.

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

Use the exported resolveProgressRatio rather than re-deriving value / max. It is the same function Root uses, so an out-of-range value that renders full 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 settles after each value change. asChild swaps the indicator’s frame for your own. The default recipe settles in 0.12s — createProgressResponseRecipe with a longer duration suits health bars. This is one of the few transitions with a default, because the fill is geometry the primitive computes.

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

Root accepts a value and a max (default 100, floored to at least 1), clamps the value to [0, max] and derives the ratio. Pass value to drive it from app state or defaultValue to run uncontrolled — nothing inside the primitive changes the value on its own.

Root renders no GuiObject, only context (value, max, ratio, indeterminate). The track is whatever frame you place Root inside.

The shared math

clampProgressValue and resolveProgressRatio are exported and used internally by Root, so a percent label built on them can never disagree with the rendered fill.

Indeterminate

indeterminate resolves the shared ratio to a fixed 0.25 for anything reading context, and the indicator renders a 0.35-width fill. The value props are still accepted and clamped, so switching the flag off animates straight to the real ratio.

Indicator structure and motion

Progress.Indicator renders a transparent, clipped window anchored to the left of your track, animating its Size toward UDim2.fromScale(ratio, 1). The fill inside always spans the window at full scale.

Pass transition to override the recipe, or rebuild it with createProgressResponseRecipe(duration). By default the fill is a solid frame with any children inside. Under asChild your child becomes the fill, with its Position and Size forced to span the window.

Spinner rotation

Progress.Spinner advances Rotation by speedDegPerSecond (default 180) times the frame delta on Heartbeat. With spinning false the connection is disconnected and the element hidden, and rotation is not reset, so re-enabling resumes from the last angle. Under asChild your child is the one rotated.

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.