Progress
Displays an indicator showing the completion progress of a task.
Default
A progress bar at 33% completion.
Various Values
Progress bars at different completion levels.
0%
25%
50%
75%
100%
Circular
Server-rendered SVG indicators use the same progress semantics and anatomy as the linear form.
Small
Medium
Large
With Label
Progress bar with a percentage label above.
Uploading...66%
Processing...45%
Complete!100%
FreeProgress
Linear and circular task completion indicators.
Install with the Proa CLI
$ proa ui add progressInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →recipe.rs
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Progress recipe — static named slots for the track and indicator.
//!
//! Progress has no visual axes today, but it is still a multipart component:
//! the public component owns the track while the indicator is a distinct
//! styled part. Keeping both slots in one recipe prevents their styling from
//! drifting apart and leaves a typed extension point for future axes.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
mod base {
use super::*;
pub const PROGRESS_TRACK: &str = tw_join!(
"relative",
"h-2",
"w-full",
"overflow-hidden",
Progress source
Browse and copy the reviewed source included with this Free component.
md.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
use super::{CircularProgress, Progress};
use crate::components::md_support::block_boundary;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
use proa_macros::md_sync;
/// Progress renders as a "Progress: 45%" paragraph.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf> MdRenderSync<Loader, B> for Progress {
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
md_sync! { "Progress: " { self.value } "%" }.render_md(cx)?;
block_boundary(cx)?;
Ok(())
}
}
impl<Loader: ::proa_core::DataLoader, B: WriteBuf> MdRenderSync<Loader, B> for CircularProgress {
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
md_sync! { "Progress: " { self.value.min(100) } "%" }.render_md(cx)?;
block_boundary(cx)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn progress_renders_percentage() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Progress {
value: 75,
..Default::default()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "Progress: 75%\n\n");
}
#[test]
fn circular_progress_has_the_same_markdown_contract() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
CircularProgress {
value: 140,
..CircularProgress::new()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "Progress: 100%\n\n");
}
}
mod.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
#![allow(clippy::module_inception)]
//! Progress component - displays a progress bar indicating completion percentage
//!
//! The Progress component shows task completion as a horizontal bar.
//! It is a pure CSS component with no JS hydration needed.
mod md;
mod progress;
mod recipe;
// Re-export progress types
pub use progress::{CircularProgress, Progress, PROGRESS_ANATOMY};
// Re-export recipe types
pub use recipe::{
progress_indicator_classes, progress_recipe, progress_track_classes, CircularProgressStyle,
ProgressRecipe, ProgressRecipeProps, ProgressSize, ProgressStyle, PROGRESS_RECIPE,
};
progress.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Progress component implementation
//!
//! Renders a progress bar showing task completion percentage.
//! Pure CSS component with no JS hydration needed.
use super::recipe::{ProgressRecipeProps, ProgressSize, PROGRESS_RECIPE};
use crate::Anatomy;
use proa_core::{WebContext, WebRenderSync, WriteBuf};
use proa_macros::{html_sync, text};
/// Stable DOM contract shared by linear and circular progress renderers.
pub const PROGRESS_ANATOMY: Anatomy =
Anatomy::new("progress", &["root", "track", "indicator", "value-text"]);
/// Progress component - displays a progress bar indicating completion percentage
///
/// # Example
///
/// ```ignore
/// use proa_ui::Progress;
/// use proa_macros::html_sync;
///
/// html_sync! {
/// <div>
/// {Progress {
/// value: 33,
/// ..Progress::new()
/// }}
/// </div>
/// }.render(cx);
/// ```
pub struct Progress {
/// Progress value from 0 to 100
pub value: u8,
/// Additional CSS classes for the track
pub class: Option<&'static str>,
/// HTML id attribute
pub id: Option<&'static str>,
/// Accessible label for screen readers
pub aria_label: Option<&'static str>,
}
impl Progress {
/// Create a new progress bar with default settings (0%)
pub const fn new() -> Self {
Self {
value: 0,
class: None,
id: None,
aria_label: None,
}
}
}
impl Default for Progress {
fn default() -> Self {
Self::new()
}
}
impl<B: WriteBuf, L: ::proa_core::DataLoader> WebRenderSync<L, B> for Progress {
#[inline(always)]
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = PROGRESS_RECIPE.resolve(ProgressRecipeProps);
let value = self.value.min(100);
let translate = 100u8.saturating_sub(value);
html_sync! {
<div
_a11y_ok
data-progress
data-scope={PROGRESS_ANATOMY.scope()}
data-part="root"
data-slot="progress"
role="progressbar"
aria-valuenow={value}
aria-valuemin="0"
aria-valuemax="100"
class={text!("{} {}", style, self.class)}
id={self.id}
aria-label={self.aria_label}
>
<div
data-progress-indicator
data-scope={PROGRESS_ANATOMY.scope()}
data-part="indicator"
data-slot="progress-indicator"
class={PROGRESS_RECIPE.indicator}
style={proa_core::style_ok(text!("transform: translateX(-{}%)", translate))}
></div>
</div>
}
.render(cx)
}
}
/// Circular progress indicator rendered as server-side SVG.
pub struct CircularProgress {
/// Progress value from 0 to 100.
pub value: u8,
pub size: Option<ProgressSize>,
pub stroke_width: u8,
pub class: Option<&'static str>,
pub id: Option<&'static str>,
pub aria_label: Option<&'static str>,
}
impl CircularProgress {
pub const fn new() -> Self {
Self {
value: 0,
size: None,
stroke_width: 3,
class: None,
id: None,
aria_label: None,
}
}
}
impl Default for CircularProgress {
fn default() -> Self {
Self::new()
}
}
impl<B: WriteBuf, L: ::proa_core::DataLoader> WebRenderSync<L, B> for CircularProgress {
#[inline(always)]
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let value = self.value.min(100);
let dash_offset = 100u8.saturating_sub(value);
let style = PROGRESS_RECIPE.resolve_circular(self.size);
html_sync! {
<svg
_a11y_ok
data-progress
data-progress-circular
data-scope={PROGRESS_ANATOMY.scope()}
data-part="root"
data-slot="progress-circular"
data-size={style.size().as_str()}
id={self.id}
role="progressbar"
aria-label={self.aria_label}
aria-valuenow={value}
aria-valuemin="0"
aria-valuemax="100"
viewBox="0 0 36 36"
class={text!("{} {}", style, self.class)}
>
<circle
data-scope={PROGRESS_ANATOMY.scope()}
data-part="track"
data-slot="progress-circular-track"
cx="18"
cy="18"
r="15.9155"
pathLength="100"
stroke-width={self.stroke_width}
class={PROGRESS_RECIPE.circular_track}
/>
<circle
data-progress-indicator
data-scope={PROGRESS_ANATOMY.scope()}
data-part="indicator"
data-slot="progress-circular-indicator"
cx="18"
cy="18"
r="15.9155"
pathLength="100"
stroke-linecap="round"
stroke-dasharray="100"
stroke-dashoffset={dash_offset}
stroke-width={self.stroke_width}
class={PROGRESS_RECIPE.circular_indicator}
/>
</svg>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::ctx::Ctx;
#[test]
fn progress_renders_with_defaults() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Progress::new().render(&mut cx).unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-progress"));
assert!(html.contains("data-slot=\"progress\""));
assert!(html.contains("role=\"progressbar\""));
assert!(html.contains("aria-valuenow=\"0\""));
assert!(html.contains("aria-valuemin=\"0\""));
assert!(html.contains("aria-valuemax=\"100\""));
assert!(html.contains("data-progress-indicator"));
assert!(html.contains("data-slot=\"progress-indicator\""));
assert!(html.contains("translateX(-100%)"));
}
#[test]
fn progress_renders_partial_value() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Progress {
value: 33,
..Progress::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("aria-valuenow=\"33\""));
assert!(html.contains("translateX(-67%)"));
}
#[test]
fn progress_renders_full_value() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Progress {
value: 100,
..Progress::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("aria-valuenow=\"100\""));
assert!(html.contains("translateX(-0%)"));
}
#[test]
fn progress_clamps_value_above_100() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Progress {
value: 150,
..Progress::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("aria-valuenow=\"100\""));
assert!(html.contains("translateX(-0%)"));
}
#[test]
fn progress_accepts_custom_class() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Progress {
value: 50,
class: Some("custom-class"),
..Progress::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("custom-class"));
}
#[test]
fn progress_accepts_id() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Progress {
value: 50,
id: Some("my-progress"),
..Progress::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("id=\"my-progress\""));
}
#[test]
fn progress_accepts_aria_label() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Progress {
value: 50,
aria_label: Some("Upload progress"),
..Progress::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("aria-label=\"Upload progress\""));
}
#[test]
fn progress_has_track_classes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Progress {
value: 50,
..Progress::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("relative"));
assert!(html.contains("h-2"));
assert!(html.contains("rounded-full"));
assert!(html.contains("bg-primary/20"));
}
#[test]
fn progress_has_indicator_classes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Progress {
value: 50,
..Progress::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("bg-primary"));
assert!(html.contains("transition-all"));
}
#[test]
fn progress_size_is_reasonable() {
assert!(std::mem::size_of::<Progress>() < 64);
assert!(!std::mem::needs_drop::<Progress>());
const _PROGRESS: Progress = Progress::new();
}
#[test]
fn circular_progress_renders_server_side_svg_anatomy() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
CircularProgress {
value: 35,
size: Some(ProgressSize::Lg),
aria_label: Some("Upload progress"),
..CircularProgress::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.starts_with("<svg"));
assert!(html.contains(r#"data-scope="progress""#));
assert!(html.contains(r#"data-part="root""#));
assert!(html.contains(r#"data-part="track""#));
assert!(html.contains(r#"data-part="indicator""#));
assert!(html.contains(r#"data-size="lg""#));
assert!(html.contains(r#"aria-valuenow="35""#));
assert!(html.contains(r#"stroke-dashoffset="65""#));
assert!(html.contains("size-16"));
}
#[test]
fn circular_progress_clamps_values() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
CircularProgress {
value: 200,
..CircularProgress::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains(r#"aria-valuenow="100""#));
assert!(html.contains(r#"stroke-dashoffset="0""#));
}
}
recipe.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Progress recipe — static named slots for the track and indicator.
//!
//! Progress has no visual axes today, but it is still a multipart component:
//! the public component owns the track while the indicator is a distinct
//! styled part. Keeping both slots in one recipe prevents their styling from
//! drifting apart and leaves a typed extension point for future axes.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
mod base {
use super::*;
pub const PROGRESS_TRACK: &str = tw_join!(
"relative",
"h-2",
"w-full",
"overflow-hidden",
"rounded-full",
"bg-primary/20"
);
pub const PROGRESS_INDICATOR: &str = tw_join!(
"h-full",
"w-full",
"flex-1",
"rounded-full",
"bg-primary",
"transition-all"
);
pub const CIRCULAR_ROOT: &str =
tw_join!("inline-flex", "shrink-0", "text-primary", "-rotate-90");
pub const CIRCULAR_TRACK: &str = tw_join!("fill-none", "stroke-current", "opacity-20");
pub const CIRCULAR_INDICATOR: &str = tw_join!(
"fill-none",
"stroke-current",
"transition-[stroke-dashoffset]",
"duration-300"
);
}
/// Diameter variants for [`super::CircularProgress`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "size")]
pub enum ProgressSize {
Sm,
#[default]
Md,
Lg,
}
impl ProgressSize {
pub const fn classes(self) -> &'static str {
match self {
Self::Sm => "size-6",
Self::Md => "size-10",
Self::Lg => "size-16",
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Sm => "sm",
Self::Md => "md",
Self::Lg => "lg",
}
}
}
/// Optional inputs accepted by [`ProgressRecipe::resolve`].
///
/// This is intentionally empty until Progress gains a genuine visual axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ProgressRecipeProps;
/// Static named slots for the Progress compound component.
#[derive(Debug, Clone, Copy)]
pub struct ProgressRecipe {
pub root: &'static str,
pub indicator: &'static str,
pub circular_root: &'static str,
pub circular_track: &'static str,
pub circular_indicator: &'static str,
}
impl ProgressRecipe {
/// Resolve the track style without allocating class storage.
pub const fn resolve(&'static self, _props: ProgressRecipeProps) -> ProgressStyle {
ProgressStyle { recipe: self }
}
pub const fn resolve_circular(
&'static self,
size: Option<ProgressSize>,
) -> CircularProgressStyle {
CircularProgressStyle {
recipe: self,
size: match size {
Some(size) => size,
None => ProgressSize::Md,
},
}
}
}
/// Fully resolved, streamable Progress track style.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct ProgressStyle {
recipe: &'static ProgressRecipe,
}
impl AttrValue for ProgressStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.recipe.root.as_bytes())
}
}
/// Fully resolved, streamable circular progress root style.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct CircularProgressStyle {
recipe: &'static ProgressRecipe,
size: ProgressSize,
}
impl CircularProgressStyle {
pub const fn size(self) -> ProgressSize {
self.size
}
}
impl AttrValue for CircularProgressStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.recipe.circular_root.as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.size.classes().as_bytes())
}
}
/// Global, zero-initialization Progress recipe.
pub const PROGRESS_RECIPE: ProgressRecipe = ProgressRecipe {
root: base::PROGRESS_TRACK,
indicator: base::PROGRESS_INDICATOR,
circular_root: base::CIRCULAR_ROOT,
circular_track: base::CIRCULAR_TRACK,
circular_indicator: base::CIRCULAR_INDICATOR,
};
pub const fn progress_recipe() -> &'static ProgressRecipe {
&PROGRESS_RECIPE
}
impl crate::variant_spec::ComponentSpec for ProgressRecipe {
const NAME: &'static str = "Progress";
fn base_classes(&self) -> &'static str {
self.root
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
Vec::new()
}
fn anatomy(&self) -> Option<crate::Anatomy> {
Some(super::PROGRESS_ANATOMY)
}
}
/// Compatibility resolver for the former track helper.
pub const fn progress_track_classes() -> ProgressStyle {
PROGRESS_RECIPE.resolve(ProgressRecipeProps)
}
/// Compatibility accessor for the former indicator helper.
pub const fn progress_indicator_classes() -> &'static str {
PROGRESS_RECIPE.indicator
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recipe_exposes_track_and_indicator_slots() {
let recipe = progress_recipe();
assert!(recipe.root.contains("relative"));
assert!(recipe.root.contains("rounded-full"));
assert!(recipe.root.contains("bg-primary/20"));
assert!(recipe.indicator.contains("h-full"));
assert!(recipe.indicator.contains("bg-primary"));
assert!(recipe.indicator.contains("transition-all"));
assert!(recipe.circular_root.contains("text-primary"));
assert!(recipe.circular_track.contains("opacity-20"));
assert!(recipe.circular_indicator.contains("stroke-dashoffset"));
}
#[test]
fn recipe_and_resolved_style_are_const_and_copy() {
const RECIPE: &ProgressRecipe = &PROGRESS_RECIPE;
const STYLE: ProgressStyle = RECIPE.resolve(ProgressRecipeProps);
let copied = STYLE;
assert!(copied.recipe.root.contains("w-full"));
}
#[test]
fn circular_recipe_resolves_sizes_without_allocation() {
let style = PROGRESS_RECIPE.resolve_circular(Some(ProgressSize::Lg));
assert_eq!(style.size(), ProgressSize::Lg);
assert_eq!(style.size().classes(), "size-16");
}
}