Components Progress

Petal Pro is the full SaaS app this is built for

Auth, billing, admin, and Claude Code integration included. One purchase, unlimited projects.

Progress

Determinate progress on a washed track, with labels and a screen-reader-polite status line.
Semantic colours

Determinate progress on a washed track. color tints the bar with the semantic range - success for healthy, warning and danger as budgets run down.

heex
<div class="w-full max-w-md space-y-4">
<.progress value={80} color="success" />
<.progress value={55} color="info" />
<.progress value={35} color="warning" />
<.progress value={15} color="danger" />
</div>
Sizes

size runs xs to xl. The thin end suits page-top loading strips; xl is the only size tall enough to carry a label inside the bar.

heex
<div class="w-full max-w-md space-y-4">
<.progress :for={z <- ~w(xs sm md lg xl)} value={60} size={z} />
</div>
Labels and status

label renders inside the bar at xl (the only size tall enough); label_position="top" puts it in a label row above the bar at any size. status adds the "Downloading assets..." line under the bar in a polite live region, so screen readers hear stage changes without being spammed by every percent.

Upload progress 56%
Installing 45%
Downloading assets (12 of 30)...
heex
<div class="w-full max-w-md space-y-6">
<.progress value={60} size="xl" label="60%" />
<.progress value={56} size="sm" label="Upload progress" label_position="top" />
<.progress
value={45}
size="sm"
label="Installing"
label_position="top"
status="Downloading assets (12 of 30)..."
/>
</div>
Ring

The same component, drawn round. Same value, max, size and color attrs as the bar, so the two shapes stay in step on a page that uses both. The arc starts at 12 o'clock and rides currentColor, so a text class recolours it. show_value drops the percentage in the hole at lg and xl - below that the hole is too small to read a number in, so it draws nothing.

heex
<div class="flex flex-col items-center gap-8">
<div class="flex flex-wrap items-center justify-center gap-6">
<.progress_ring :for={z <- ~w(xs sm md lg xl)} value={68} size={z} />
</div>
<div class="flex flex-wrap items-center justify-center gap-6">
<.progress_ring value={92} size="lg" color="success" show_value />
<.progress_ring value={64} size="lg" color="info" show_value />
<.progress_ring value={38} size="lg" color="warning" show_value />
<.progress_ring value={11} size="lg" color="danger" show_value />
</div>
</div>
Labels beside the ring

Where the readout goes when the ring is small. No attr for this one - it's a flex row, tabular-nums so the numbers hold their column, and the ring shrunk to sit on the text's line. Outside text also buys you room for words: a lg ring can keep its own percentage and still carry a title and a line of detail next to it.

62%
38%
94%
Storage used
6.8 GB of 10 GB. Two more exports and you're buying space.
heex
<div class="w-full max-w-md space-y-8">
<div class="flex flex-wrap items-center gap-x-8 gap-y-3">
<span
:for={{value, color} <- [{62, "primary"}, {38, "warning"}, {94, "success"}]}
class="flex items-center gap-2 text-sm text-gray-700 tabular-nums dark:text-gray-300"
>
<.progress_ring value={value} size="sm" color={color} label="Completion" />
{value}%
</span>
</div>
<div class="flex items-center gap-4">
<.progress_ring value={68} size="lg" color="info" show_value label="Storage used" />
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Storage used</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
6.8 GB of 10 GB. Two more exports and you're buying space.
</div>
</div>
</div>
</div>
Rings in a table

The one that earns its keep: a 16px ring beside the number, read down a column at a glance. Five bars stacked in five rows is a barcode; five rings is a shape you can scan. The middle takes a slot too, so it can hold a count instead of a percentage.

Design system
92%
Billing rewrite
61%
Docs backlog
18%
The slot wins over show_value, so the middle can carry a count or an icon.
heex
<div class="w-full max-w-md">
<div class="divide-y divide-gray-200 dark:divide-gray-800">
<div class="flex items-center justify-between gap-4 py-3">
<span class="text-sm">Design system</span>
<span class="flex items-center gap-2 text-sm text-gray-500 tabular-nums dark:text-gray-400">
<.progress_ring value={92} size="xs" color="success" label="Design system" /> 92%
</span>
</div>
<div class="flex items-center justify-between gap-4 py-3">
<span class="text-sm">Billing rewrite</span>
<span class="flex items-center gap-2 text-sm text-gray-500 tabular-nums dark:text-gray-400">
<.progress_ring value={61} size="xs" color="info" label="Billing rewrite" /> 61%
</span>
</div>
<div class="flex items-center justify-between gap-4 py-3">
<span class="text-sm">Docs backlog</span>
<span class="flex items-center gap-2 text-sm text-gray-500 tabular-nums dark:text-gray-400">
<.progress_ring value={18} size="xs" color="warning" label="Docs backlog" /> 18%
</span>
</div>
</div>
<div class="flex items-center gap-4 pt-6">
<.progress_ring value={12} max={30} size="lg" color="secondary" label="Files uploaded">
<span class="text-xs">12/30</span>
</.progress_ring>
<div class="text-sm text-gray-500 dark:text-gray-400">
The slot wins over show_value, so the middle can carry a count or an icon.
</div>
</div>
</div>
Properties
<.progress>
Attribute Type Default Description
class any nil CSS class
color string "primary"
one of: "primary", "secondary", "info", "success", "warning", "danger", "gray"
label string nil labels your progress bar
label_position string "inside" inside renders the label in the bar itself (xl only); top renders a label row with the percentage above the bar, at any size
one of: "inside", "top"
max integer 100 sets a max value for your progress bar
rest global
size string "md"
one of: "xs", "sm", "md", "lg", "xl"
status string nil a status line under the bar - "Downloading assets..." - announced politely to screen readers as it changes
value integer nil adds a value to your progress bar
<.progress_ring>
Attribute Type Default Description
class any nil CSS class
color string "primary"
one of: "primary", "secondary", "info", "success", "warning", "danger", "gray"
label string nil the accessible name, same as on the bar - not drawn, screen readers only
max integer 100 sets a max value for your progress ring
rest global
show_value boolean false draws the rounded percentage in the middle. lg and xl only - below that the hole is too small to read a number in, so it draws nothing and the readout goes beside the ring
size string "md"
one of: "xs", "sm", "md", "lg", "xl"
value integer nil how far along you are
:inner_block slot slot custom middle content ("12/30", an icon) - takes over from show_value, and unlike show_value it renders at every size, so below lg keep it to something that survives a 40px hole