Roll20 uses cookies to improve your experience on our site. Cookies enable you to enjoy certain features, social sharing functionality, and tailor message and display ads to your interests on our site and others. They also help us understand how our site is being used. By continuing to use our site, you consent to our use of cookies. Update your cookie preferences .
×
Create a free account

Smooth custom progress bar that works for any resource with min and max values

1692448246

Edited 1692465340
Hey! I've been making my own little character sheet for the past few weeks, and I just wanted to share a progress bar that I made. It's nothing revolutionary but it feels really smooth and provides a few features that a lot of people would want from a progress bar. I've been looking around the forums and people have asked for a progress bar like this, theorised about it, but never actually show a working example  (as far as I know) , so here it is: HTML: <input name="attr_hp_max" title="@{hp_max}" contenteditable="true" type="number" value="10"> <input name="attr_hp" title="@{hp}" contenteditable="true" type="number" value="10"> <div class="bar-track"> <input type="hidden" class="bar-value" name="attr_bar_value" value="20" /> <div class="bar-progress"></div> </div> CSS: /* ----- BAR TRACK ----- */ .charsheet .bar-track { background-color: #313031; /* Dark Grey */ width: 100px; height: 20px; border-radius: 5px; overflow: hidden; } /* ----- BAR PROGRESS ----- */ .charsheet .bar-progress { background-color: #77b05f; /* Green */ width: 100%; height: 20px; transition: width 0.25s ease-in-out, background-color 0.25s ease-in-out; } /* ----- BAR PROGRESS STEPS ----- */ .charsheet .bar-value[value^="-"] ~ div.bar-progress, .charsheet .bar-value[value="0"] ~ div.bar-progress { background-color: #da5b70; /* Red */ width: 0%; } .charsheet .bar-value[value^="0."] ~ div.bar-progress, .charsheet .bar-value[value="1"] ~ div.bar-progress, .charsheet .bar-value[value^="1."] ~ div.bar-progress { background-color: #da5b70; /* Red */ width: 5%; } .charsheet .bar-value[value="2"] ~ div.bar-progress, .charsheet .bar-value[value^="2."] ~ div.bar-progress { background-color: #da5b70; /* Red */ width: 10%; } .charsheet .bar-value[value="3"] ~ div.bar-progress, .charsheet .bar-value[value^="3."] ~ div.bar-progress { background-color: #da5b70; /* Red */ width: 15%; } .charsheet .bar-value[value="4"] ~ div.bar-progress, .charsheet .bar-value[value^="4."] ~ div.bar-progress { background-color: #da5b70; /* Red */ width: 20%; } .charsheet .bar-value[value="5"] ~ div.bar-progress, .charsheet .bar-value[value^="5."] ~ div.bar-progress { background-color: #da5b70; /* Red */ width: 25%; } .charsheet .bar-value[value="6"] ~ div.bar-progress, .charsheet .bar-value[value^="6."] ~ div.bar-progress { background-color: #e9a355; /* Yellow */ width: 30%; } .charsheet .bar-value[value="7"] ~ div.bar-progress, .charsheet .bar-value[value^="7."] ~ div.bar-progress { background-color: #e9a355; /* Yellow */ width: 35%; } .charsheet .bar-value[value="8"] ~ div.bar-progress, .charsheet .bar-value[value^="8."] ~ div.bar-progress { background-color: #e9a355; /* Yellow */ width: 40%; } .charsheet .bar-value[value="9"] ~ div.bar-progress, .charsheet .bar-value[value^="9."] ~ div.bar-progress { background-color: #e9a355; /* Yellow */ width: 45%; } .charsheet .bar-value[value="10"] ~ div.bar-progress, .charsheet .bar-value[value^="10."] ~ div.bar-progress { background-color: #e9a355; /* Yellow */ width: 50%; } .charsheet .bar-value[value="11"] ~ div.bar-progress, .charsheet .bar-value[value^="11."] ~ div.bar-progress { background-color: #77b05f; /* Green */ width: 55%; } .charsheet .bar-value[value="12"] ~ div.bar-progress, .charsheet .bar-value[value^="12."] ~ div.bar-progress { background-color: #77b05f; /* Green */ width: 60%; } .charsheet .bar-value[value="13"] ~ div.bar-progress, .charsheet .bar-value[value^="13."] ~ div.bar-progress { background-color: #77b05f; /* Green */ width: 65%; } .charsheet .bar-value[value="14"] ~ div.bar-progress, .charsheet .bar-value[value^="14."] ~ div.bar-progress { background-color: #77b05f; /* Green */ width: 70%; } .charsheet .bar-value[value="15"] ~ div.bar-progress, .charsheet .bar-value[value^="15."] ~ div.bar-progress { background-color: #77b05f; /* Green */ width: 75%; } .charsheet .bar-value[value="16"] ~ div.bar-progress, .charsheet .bar-value[value^="16."] ~ div.bar-progress { background-color: #77b05f; /* Green */ width: 80%; } .charsheet .bar-value[value="17"] ~ div.bar-progress, .charsheet .bar-value[value^="17."] ~ div.bar-progress { background-color: #77b05f; /* Green */ width: 85%; } .charsheet .bar-value[value="18"] ~ div.bar-progress, .charsheet .bar-value[value^="18."] ~ div.bar-progress { background-color: #77b05f; /* Green */ width: 90%; } .charsheet .bar-value[value="19"] ~ div.bar-progress, .charsheet .bar-value[value^="19."] ~ div.bar-progress { background-color: #77b05f; /* Green */ width: 95%; } .charsheet .bar-value[value="20"] ~ div.bar-progress, .charsheet .bar-value[value^="20."] ~ div.bar-progress { background-color: #77b05f; /* Green */ width: 100%; } SHEETWORKER: <script type="text/worker"> on("change:hp change:hp_max sheet:opened", function() { update_progressBar(); }); var update_progressBar = function () {          getAttrs ([ 'hp' , 'hp_max' ], function ( v ) {              const output = {};              const hp_max = + v . hp_max || 0 ;              const hp = + v . hp || 0 ;              const hp_real = ( hp > hp_max ) ? hp_max : ( hp < 0 ) ? 0 : hp ;              output . bar_value = ( hp_real / hp_max ) * 20 ;              if ( hp_real != hp ) output . hp = hp_real ;              setAttrs ( output );          });      }; </script> There you go, you can add more or less steps if you want. You can change the colors to whatever you want, the size is fully customisable too. It'll force the HP input to fit the range of 0 to max HP, and smoothly move the progress bar and change the color to reflect the change. It'll work whatever your HP and max HP values are  (as long as max HP is bigger than 0) . It's a really good alternative to range inputs if you want your bar to look really slick. Of course big shoutout to  Leothedino for the original CSS step concept. I took the idea and pushed it a bit further to fit what a lot of people seem to want from their custom progress bar. And thank you GiGs for the help with cleaning the code!
1692455368

Edited 1692455849
GiGs
Pro
Sheet Author
API Scripter
The concept looks very nice, but I would change a few things in the code. First, change the very first input from <input type="hidden" title="@{maxhp}" name="attr_maxhp" value="100" /> to <input type="number" title="@{hp_max}" name="attr_hp_max" value="100" /> This means that when hp is assigned to a token bar , the max value is also filled in automatically (roll20 looks for the _max of any attribute). You also don't want the max hp to be hidden - people need to enter that value. That's their real HP, after all. Also the value="100" for each seems a little high - its probably inappropriate for most games. value="1" might be better - it'll take whatever values people enter, anyway. The biggest change is in the sheet worker. You don't want multiple setAttrs functions for reasons of efficiency. But you can create an object to hold all the values you want to set, and set them all in one swoop. var update_progressBar = function () {     getAttrs ([ 'hp' , 'hp_max' ], function ( v ) {         const output = {};         const hp_max = + v . hp_max || 0 ;         const hp = + v . hp || 0 ;         const hp_real = ( hp > hp_max ) ? hp_max : ( hp < 0 ) ? 0 : hp ;         output . bar_value = Math . floor ( hp_real / hp_max * 20 );         if ( hp_real != hp ) output . hp = hp_real ;         setAttrs ( output );     }); }; This only alters hp if its outside the range of 0 - max, and since it rounds bar_value to whole numbers, it can reduce your CSS a bit. Instead of this: .charsheet .sheet-bar-value[value="20"] ~ div.sheet-bar-progress, .charsheet .sheet-bar-value[value^="20."] ~ div.sheet-bar-progress { you'd use .charsheet .sheet-bar-value[value="20"] ~ div.sheet-bar-progress { I'd also suggest using non-legacy css so you can type that as .charsheet .bar-value[value="20"] ~ div.bar-progress { Alos you don't need this: .charsheet .sheet-bar-value[value^="-"] ~ div.sheet-bar-progress, The value is being set in the sheet worker from the moment the sheet is first opened, and will always be a number from 0 to 20. It will never begin with "-"
Thank you so much for the feedback! I clearly have a lot of things to learn. I'll get to fixing all that. Didn't know about the _max suffix, but I only put the max value as hidden for demonstration purposes if someone wants to drag and drop this code into a sandbox. I purposefully didn't use .floor .ceil or .round because I need to be able to differenciate between the highest / lowest value and the min / max. I don't want the player to have 1 hp and it looks like he has none, or if he has 99 / 100 hp it looks like the bar is full. So I'm leaving the decimal places and using this system. Also the "-" is useful if a player decides to put an invalid negative input into the field. It's just foolproofing.
1692456123

Edited 1692456153
GiGs
Pro
Sheet Author
API Scripter
Matt said: I purposefully didn't use .floor .ceil or .round because I need to be able to differenciate between the highest / lowest value and the min / max. I don't want the player to have 1 hp and it looks like he has none, or if he has 99 / 100 hp it looks like the bar is full. So I'm leaving the decimal places and using this system. Also the "-" is useful if a player decides to put an invalid negative input into the field. It's just foolproofing. Those are sensible decisions. This approach should never have the 99/100 issue, but will have the 0/1 issue. That can be fixed in the sheet worker with a bit more effort (using ceil to round up, and set the maximum to 21 maybe), but your reasoning is sound. You can stop a player manually entering a value (with readonly property), and have the sheet worker run on changes there too (using eventInfo to check its a manual change not an automatic one), to ensure that the value never begins with -. But things are  getting a bit complex there :)
Alright! I updated the main post with your recommendations. Thank you so much again.
1692462755

Edited 1692463279
vÍnce
Pro
Sheet Author
Thanks for sharing this Matt.  Maybe this will get included with the wiki ...
That'd be awesome! 
1692465557

Edited 1692465572
GiGs
Pro
Sheet Author
API Scripter
Good idea, Vince. Matt, anyone can add to the wiki - you oculd add this yourself.
1692472614

Edited 1692473932
Scott C.
Forum Champion
Sheet Author
API Scripter
Compendium Curator
Nice job Matt! There are also some javascript functions, and CSS tricks and new properties that we can take advantage of to make this more streamlined. I've also made some minor edits to the HTML to set things up for the CSS changes. HTML <label for="">HP:<input name="attr_hp" title="@{hp}" contenteditable="true" type="number" value="10"></label> <label for="">Max:<input name="attr_hp_max" title="@{hp_max}" contenteditable="true" type="number" value="10"></label> <input type="hidden" class="bar-value" name="attr_bar_value" value="100" /> <div class="bar-track"> <div class="bar-progress"></div> </div> The hp inputs are just me adding labels so I could remember which input does what. The big change is moving the value input out of the track and setting it's default to 100. This is so that we can control our css variables at a higher level so that they cascade their values correctly. Javascript var update_progressBar = function (event) { // make the function generic so it can be used by any current/max attribute pairing const baseAttributeName = event.sourceAttribute.replace(/_max/,''); getAttrs([baseAttributeName, `${baseAttributeName}_max`], function (v) { const output = {}; const hp_max = +v.hp_max || 1; // We can use the Math functions of JS to limit our hp to valid values const hp = Math.min(Math.max(+v.hp || 0,0),hp_max); output.bar_value = (hp / hp_max) * 100; setAttrs(output); }); }; on("change:hp change:hp_max", update_progressBar); Functionally, nothing has changed in the javascript. I've just taken advantage of the Math.max and Math.min functions to limit our hp variable to the max and min of our progress bar. This then means that players can input a negative and it won't break anything. Additionally, the progress bar now scales from 0 - 100% instead of 0 - 20. Additionally, I've set the sheetworker up so it's generic and can be used with any current/max attribute pairing. CSS The CSS is where things get a lot different. I'm going to use CSS variables and the new color-mix function to make it easier to specify steps (going all the way to steps incrementing by 0.1). Basic setup /* ----- BAR TRACK ----- */ .bar-track { background-color: #313031; /* Dark Grey */ width: 100px; height: 20px; border-radius: 5px; overflow: hidden; place-items:center start; display:grid; grid-template-areas:'content'; --tensSize:0%; --onesSize:0%; --decimalSize:0%; --trackGoodColor:green; --trackBadColor:red; } /* ----- BAR PROGRESS ----- */ .bar-progress { /* Note that this calculation is done in this element so that the changing values of the size variables cascade to it properly and update the value as the sizes are updated */ --trackPercentage: calc(var(--tensSize) + var(--onesSize) + var(--decimalSize)); box-sizing: border-box; grid-area:content; background-color: color-mix(in oklab,var(--trackGoodColor) var(--trackPercentage),var(--trackBadColor)); /* Green fading to red as damage taken */ width: var(--trackPercentage); height: 100%; transition: width 0.25s ease-in-out, background-color 0.25s ease-in-out; } This is pretty similar to what you had, except I've defined some CSS variables that are going to be manipulated based on the value of our progress value. The Size variables (e.g. --tensSize) will define how much the width (and color mix) should shift based on the tens value of the progress value. The color values define the range of our color transition (red - green in this case). In addition, I've added display grid (and a grid-template-area) to our track. This will allow us to easily place and manipulate the display of our track. Right now the track is setup to be a standard fill from left track, but you could easily change it to fill from the center out (change place-items to be place-items:center;) or to be a thin line that doesn't take up the full vertical width of the tracker (change the progress height to a lower percentage). The big changes occur in .bar-progress . I define yet another css variable called  --trackPercentage that calculates the sum of our various sizes. This is defined as a variable because we're going to use it in a couple different places, and this way I only have to write the calculation once. And that percentage variable is used to define our color mix (literally how much of our good color we want and how much of the bad color. It's also used to specify how wide our progress bar should be. Right now, this is of course static at displaying as if the value is 0% of the maximum. That'll change in the next section. Styling for a given value And then we get to the biggest section of code, styling for the various steps. All we need to do to style for the various steps is manipulate the various size variables based on the value at that numeral position. That looks like this: /* ----- BAR PROGRESS STEPS ----- */ /* 10's steps */ .bar-value[value^="1"]:not([value^="1."]):not([value="1"]) + .bar-track{ --tensSize: 10%; } .bar-value[value^="2"]:not([value^="2."]):not([value="2"]) + .bar-track{ --tensSize: 20%; } .bar-value[value^="3"]:not([value^="3."]):not([value="3"]) + .bar-track{ --tensSize: 30%; } .bar-value[value^="4"]:not([value^="4."]):not([value="4"]) + .bar-track{ --tensSize: 40%; } .bar-value[value^="5"]:not([value^="5."]):not([value="5"]) + .bar-track{ --tensSize: 50%; } .bar-value[value^="6"]:not([value^="6."]):not([value="6"]) + .bar-track{ --tensSize: 60%; } .bar-value[value^="7"]:not([value^="7."]):not([value="7"]) + .bar-track{ --tensSize: 70%; } .bar-value[value^="8"]:not([value^="8."]):not([value="8"]) + .bar-track{ --tensSize: 80%; } .bar-value[value^="9"]:not([value^="9."]):not([value="9"]) + .bar-track{ --tensSize: 90%; } .bar-value[value^="10"]:not([value^="10."]):not([value="10"]) + .bar-track{ --tensSize: 100%; } /* Ones sizing */ .bar-value:is([value*="1."],[value$="1"]:not([value*="."])) + .bar-track{ --onesSize: 1% } .bar-value:is([value*="2."],[value$="2"]:not([value*="."])) + .bar-track{ --onesSize: 2% } .bar-value:is([value*="3."],[value$="3"]:not([value*="."])) + .bar-track{ --onesSize: 3% } .bar-value:is([value*="4."],[value$="4"]:not([value*="."])) + .bar-track{ --onesSize: 4% } .bar-value:is([value*="5."],[value$="5"]:not([value*="."])) + .bar-track{ --onesSize: 5% } .bar-value:is([value*="6."],[value$="6"]:not([value*="."])) + .bar-track{ --onesSize: 6% } .bar-value:is([value*="7."],[value$="7"]:not([value*="."])) + .bar-track{ --onesSize: 7% } .bar-value:is([value*="8."],[value$="8"]:not([value*="."])) + .bar-track{ --onesSize: 8% } .bar-value:is([value*="9."],[value$="9"]:not([value*="."])) + .bar-track{ --onesSize: 9% } /* decimal sizing */ .bar-value:is([value*=".0"],[value*=".1"]) + .bar-track{ --decimalSize: 0.1%; } .bar-value[value*=".2"] + .bar-track{ --decimalSize: 0.2%; } .bar-value[value*=".3"] + .bar-track{ --decimalSize: 0.3%; } .bar-value[value*=".4"] + .bar-track{ --decimalSize: 0.4%; } .bar-value[value*=".5"] + .bar-track{ --decimalSize: 0.5%; } .bar-value[value*=".6"] + .bar-track{ --decimalSize: 0.6%; } .bar-value[value*=".7"] + .bar-track{ --decimalSize: 0.7%; } .bar-value[value*=".8"] + .bar-track{ --decimalSize: 0.8%; } .bar-value[value*=".9"] + .bar-track{ --decimalSize: 0.9%; } Using the regex selectors we match for 10s values, ones values, and the first decimal value (which should give us plenty of granularity). As the various size variables are updated, the calculation of --trackPercentage in .bar-progress updates as well which then changes the progress bar's color and width accordingly. Putting it all together This looks pretty complicated, but the benefit is that we can use this styling and sheetworker for any progress bar setup we might make and don't need to redo the CSS or javascript for specific steps, attribute names, or maximum values. Additionally, if you're using a templating library like PUG, Mustache, or handlebars to build the html of your sheet, this setup can be built via a bit of resuable code instead of needing to copy/paste the html each time you need it. And it looks like this in action: EDIT: Kevin Powell has a great video on the color-mix function . The support of color-mix is pretty close to 100%, and for use in a Roll20 character sheet, we can pretty much just say it's at 100% since all the Roll20 supported browsers support it.
1692473474
vÍnce
Pro
Sheet Author
Wow.  Thanks for the level-up Scott! My CSS-Brain is hurting. ;-P
1692473826
Scott C.
Forum Champion
Sheet Author
API Scripter
Compendium Curator
Heh, it was a fun challenge! I might even add this to the K-scaffold along with some other new features that have been submitted for it.
This is amazing Scott! Thank you so much for taking the time to write down your thought process too! I'm so glad people find this interesting and that it might become helpful to others.
1692560800
vÍnce
Pro
Sheet Author
Added Scott's example to the wiki here (linked back to this thread for the details).