Files
Htmx/docs/Components/Progress.md
T
2026-05-05 23:55:26 +05:00

4.6 KiB

Progress

A horizontal bar that fills from left to right to show how complete something is. Use it for upload progress, onboarding checklists, storage usage, or anything that has a percentage value.


Quick example

new Progress(value: 72)

All the options

public Progress(int value, string size = "default")
Parameter What it does
value How filled the bar is, from 0 to 100. Values outside this range are clamped automatically.
size Height of the bar: "sm" (6px), "default" (10px), or "lg" (16px).

Real-world examples

Disk usage inside a Card

// Pre-render the Progress bar to HTML
var w = new System.Buffers.ArrayBufferWriter<byte>();
new Progress(value: usedPercent, size: "lg").Render(new HtmxRenderContext(w));
var progressHtml = System.Text.Encoding.UTF8.GetString(w.WrittenSpan);

new Card(
    title:   "Storage",
    content: $"""
        <div class="mb-2 flex justify-between text-sm">
          <span>Used</span>
          <span>{usedGb} GB / {totalGb} GB</span>
        </div>
        {progressHtml}
    """)

Live progress bar (HTMX polling)

Wrap the component in a polling <div> that swaps the fragment every second:

<div id="job-progress"
     hx-get="/jobs/42/progress"
     hx-trigger="every 1s"
     hx-swap="outerHTML">
  $$ProgressBar$$
</div>

The handler returns a fresh render of the component with the updated value. The transition-all CSS on the fill makes the change smooth.

Three sizes side by side

new Progress(value: 40, size: "sm")      // compact, good for table rows
new Progress(value: 60)                  // standard
new Progress(value: 80, size: "lg")      // prominent

How it works

Progress is two nested <div> elements. The outer one is the grey track; the inner one is the filled bar. The fill width is set as an inline style="width: {value}%" so no JavaScript is required. The transition-all class makes the bar animate smoothly when the value changes via an HTMX swap.


Complete page example

Templates/JobStatusPage.htmx

<div class="max-w-md mx-auto py-10">
  <h1 class="text-2xl font-bold mb-2">Processing</h1>
  <p class="text-sm text-muted-foreground mb-6">$$StatusText$$</p>
  <div class="mb-2 flex justify-between text-sm">
  <span>Progress</span>
  <span>$$ProgressLabel$$</span>
  </div>
  $$ProgressBar$$
  $$DoneAlert$$
</div>

Templates/JobStatusPage.htmx.cs

namespace Htmx.ApiDemo.Templates;

public sealed class JobStatusPage : JobStatusPageBase
{
  private readonly byte[] _statusText;
  private readonly byte[] _progressLabel;
  private readonly IHtmxComponent _progressBar;
  private readonly IHtmxComponent _doneAlert;

  public JobStatusPage(int percent, string statusText)
  {
    _statusText    = System.Net.WebUtility.HtmlEncode(statusText).ToUtf8Bytes();
    _progressLabel = $"{percent}%".ToUtf8Bytes();
    _progressBar   = new Components.Progress(value: percent);
    _doneAlert     = percent >= 100
      ? new Components.Alert(title: "Complete!", description: "Your export is ready.")
      : HtmxEmpty.Instance;
  }

  protected override void RenderStatusText(HtmxRenderContext ctx)    => ctx.Writer.WriteUtf8(_statusText);
  protected override void RenderProgressLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_progressLabel);
  protected override void RenderProgressBar(HtmxRenderContext ctx)   => _progressBar.Render(ctx.Next());
  protected override void RenderDoneAlert(HtmxRenderContext ctx)     => _doneAlert.Render(ctx.Next());
}

GET handler with HTMX polling

[Handler]
[MapGet("/jobs/{jobId}/status")]
public static partial class GetJobStatusHandler
{
  public record Query([property: FromRoute] string JobId);

  private static async Task<IResult> HandleAsync(
    Query q, HttpContext ctx, JobQueue jobs, CancellationToken ct)
  {
    var job = await jobs.GetAsync(q.JobId, ct);
    if (job is null) return Results.NotFound();

    var page = new JobStatusPage(job.PercentComplete, job.StatusText);

    // If polling (HTMX partial), only return the progress fragment
    if (ctx.Request.Headers.ContainsKey("HX-Request"))
    {
      // Stop polling when done
      if (job.PercentComplete >= 100)
        ctx.Response.Headers.Append("HX-Trigger", "jobComplete");

      return await ctx.WriteHtmxPage(page, title: "Processing");
    }

    // Full page load — include polling trigger
    ctx.Response.Headers.Append("HX-Trigger-After-Settle",
      """{"startPolling": {"interval": 1000, "target": "#progress-region"}}""");

    return await ctx.WriteHtmxPage(page, title: "Processing");
  }
}