Rebecca Le

@sevenseacat

Dynamic HTML progress bars with TailwindCSS

Aug 31, 2024

Recently I was tasked with finding out why a timeline component wasn't working in our Phoenix application, that shows the current progress of an operation as users mouse over different stages of it. It didn't show any progress at all - not on first page load, or not when stages were hovered over.

There's a little bit of Phoenix LiveView magic involved, but the HTML for the component boiled down to this:

<div class="bg-slate-100 h-1 w-full">
<div
class={"h-1 bg-[#{colour(@operation)}]"}
style={"width: #{progress(@operation)}%"}
/>
</div>

The dynamic colour classes were all being whitelisted in tailwind.config.js, so that wasn't the problem. It turns out that setting the width of the bar via inline styles was the problem - we'd recently added a content security policy to the app to block various kinds of attacks, and it was also blocking the inline styles on the inner div.

It turns out that HTML actually already has support for progress-type components like this - the progress HTML element. I sometimes forget about some of these newer elements (yes, HTML5 is still new to me, get off my lawn) but it turns out that these are perfect for our purpose!

Building the progress bar

The HTML can be simplified - for a basic unstyled bar, we can now have:

<div class="h-1 w-full">
<progress value={progress(@operation)} max="100" class="h-full w-full" />
</div>

When the value attribute changes, the page will re-render to show a progress bar filled to the given percentage (given that the max is set to 100). No inline styles required. ๐Ÿ˜Ž

Styling the progress bar

This is a little trickier. It is possible to style both the filled and unfilled sections of the bar, but we need to revisit the vendor-prefixed CSS selector days. Firefox uses -moz-progress-bar to target the filled section, and WebKit/Blink use -webkit-progress-bar for the unfilled section and -webkit-progress-value for the filled section.

We can wrap these up in custom TailwindCSS variants, so that our HTML is vendor-prefix free. In your tailwind.config.js file, this would look like:

// Added to the `plugins` list in the exported Tailwind config
plugin(({ addVariant }) => {
addVariant("filled", ["&::-webkit-progress-value", "&::-moz-progress-bar"])
addVariant("unfilled", ["&::-webkit-progress-bar", "&"])
}),

This means that we can now use filled and unfilled prefixes, like dark and hover and any other TailwindCSS prefixes, with any existing TailwindCSS class. Background colors, gradients, animations, anything you like. You can get pretty custom!

(Note that you do need to also add the appearance-none class, for Webkit browsers.)

Examples

Gradients? Hovers? Sure!

Classes on the progress bars:

[
# General styles
"h-full w-full align-top appearance-none rounded-full hover:scale-y-150",
# Filled styles
"filled:rounded-full filled:animate-pulse filled:bg-gradient-to-r",
"filled:from-indigo-500 filled:via-purple-500 filled:to-pink-500",
# Filled hover styles
"filled:hover:animate-none filled:hover:from-cyan-500",
"filled:hover:via-sky-500 filled:hover:to-green-500",
# Unfilled styles
"unfilled:bg-slate-100"
]

"This is how close your Kickstarter is to its goal"

This one took a bit more work, including defining some keyframes in CSS, so they can use Tailwind class names:

@keyframes size {
from { width: 0 }
to { width: 100% }
}

@keyframes orange {
from { @apply bg-red-500 }
to
{ @apply bg-orange-500 }
}

@keyframes yellow
{
from { @apply bg-red-500 }
50%
{ @apply bg-orange-500 }
to
{ @apply bg-yellow-500 }
}

@keyframes lime
{
from { @apply bg-red-500 }
30%
{ @apply bg-orange-500 }
70%
{ @apply bg-yellow-500 }
to
{ @apply bg-lime-500 }
}

@keyframes green
{
from { @apply bg-red-500 }
25%
{ @apply bg-orange-500 }
50%
{ @apply bg-yellow-500 }
75%
{ @apply bg-lime-500 }
to
{ @apply bg-green-500 }
}

Wrapping the progress bar, and dynamically applying some classes depending on the value:

<div class="h-full w-full bg-slate-100 rounded-full">
<progress
value={value}
max="100"
class={[
"h-full w-full align-top appearance-none rounded-full",
"filled:rounded-full animate-[size_2s_ease-out] unfilled:bg-slate-100",
temperature(value)
]}
/>

</div>

Where temperature(value) is defined as:

def temperature(val) do
case val do
val when val <= 20 ->
"filled:bg-red-500"
val when val in 21..40 ->
"filled:animate-[orange_2s_ease-out] filled:bg-orange-500"
val when val in 41..60 ->
"filled:animate-[yellow_2s_ease-out] filled:bg-yellow-500"
val when val in 61..80 ->
"filled:animate-[lime_2s_ease-out] filled:bg-lime-500"
_ ->
"filled:animate-[green_2s_ease-out] filled:bg-green-500"
end
end

Don't forget to safelist any custom classes that don't appear in their entirety in your templates! Our app used custom dynamic colours, so we needed to do this otherwise TailwindCSS wouldn't generate them in our CSS.

You can see all code used in this blog post in the following repository: https://github.com/sevenseacat/phoenix_tailwind_progress_bar_example

โ† Home

Want to talk tech on Twitter? You can find me at @sevenseacat!

Built using 11ty and TailwindCSS.