@lattice-ui/react-progressStable directionimport Progressdepends on runtime, motionProgress 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
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";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.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
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.
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.
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.
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%.
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.
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.
Progress.Root is a pure context provider and the indicator sizes itself in scale, so place Root inside a frame defining the bar’s pixel size and background.
A value above max renders full, a negative one empty, and a max below 1 is floored — so progress is safe to drive from raw replication. But a bug that overshoots max looks like a finished bar, so validate upstream if “over 100%” means something.
Progress.Spinner reads no Root context and can go anywhere. It keeps rotating while mounted and spinning, on screen or not — unmount long-lived spinners rather than hiding them.
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. |