CSS Progress Bars: 16 Examples + the progress Element

Most progress bars look identical on screen whether they're built well or badly. Only the native progress element carries the progressbar role natively, per MDN Web Docs. A div bar needs a role, a value and a name written by hand.

Below are 16 CSS progress bar examples you can try and copy, followed by a guide to styling, accessibility and scroll-linked fills.

By Bogdan Sandu · Updated October 28, 2026

Native progress and meter elements

Five bars built on the HTML elements themselves. The progress element reports how far a task has gone, the meter element shows a measurement inside a known range, and both are styled with appearance: none plus the vendor pseudo-elements, one rule per engine.

File upload with a cancel button

  • progress
  • ::-webkit-progress-value
  • aria-valuetext
0.0 of 24 MB
0%

A simulated upload that pushes a number into the value property every few hundred milliseconds. The visible percentage updates on every tick, but aria-valuetext only changes at each quarter and the status line only speaks at halfway and at the end, so a screen reader is not flooded. Starting moves focus to Cancel, and cancelling sends it back.

View code
.pb-up {
  box-sizing: border-box;
  width: min(100%, 380px);
  padding: 22px;
  border: 1px solid #e3e6ee;
  border-radius: 16px;
  background: #fff;
  color: #1b2030;
  box-shadow: 0 12px 32px rgba(27, 32, 48, .08);
}
.pb-up__head {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 16px;
}
.pb-up__type {
  flex: none;
  display: grid;
  place-items: center;
  width: 42px;
  height: 50px;
  border-radius: 8px;
  background: #fde8e4;
  color: #a8261b;
  font-size: 11px;
  font-weight: 800;
  letter-spacing: .06em;
}
.pb-up__meta {
  flex: 1;
  min-width: 0;
  display: grid;
  gap: 2px;
}
.pb-up__name {
  overflow: hidden;
  font-weight: 700;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.pb-up__size {
  color: #565d70;
  font-size: 13px;
  font-variant-numeric: tabular-nums;
}
.pb-up__pct {
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}
.pb-up__bar {
  -webkit-appearance: none;
  appearance: none;
  display: block;
  width: 100%;
  height: 10px;
  border: 0;
  border-radius: 999px;
  background: #e6e9f2;
  overflow: hidden;
}
.pb-up__bar::-webkit-progress-bar {
  border-radius: 999px;
  background: #e6e9f2;
}
.pb-up__bar::-webkit-progress-value {
  border-radius: 999px;
  background: #3b5bdb;
}
.pb-up__bar::-moz-progress-bar {
  border-radius: 999px;
  background: #3b5bdb;
}
.pb-up__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 18px;
}
.pb-up__actions button {
  min-height: 44px;
  padding: 0 18px;
  border-radius: 10px;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.pb-up__start {
  border: 0;
  background: #3b5bdb;
  color: #fff;
}
.pb-up__cancel {
  border: 1px solid #b9bfcf;
  background: #fff;
  color: #1b2030;
}
.pb-up__actions button:disabled {
  cursor: not-allowed;
  opacity: .5;
}
.pb-up__actions button:focus-visible {
  outline: 3px solid #1b2030;
  outline-offset: 2px;
}
.pb-up__status {
  min-height: 1.5em;
  margin: 12px 0 0;
  color: #3d4456;
  font-size: 14px;
}
@media (prefers-reduced-motion: no-preference) {
  .pb-up__bar::-webkit-progress-value {
    transition: width .2s ease-out;
  }
}

Download queue with several bars

  • progress
  • label for
  • role="status"

Offline maps

  • 180 MB
  • 420 MB
  • 64 MB

Three native bars, each named by its own label, running at different speeds from one setTimeout loop. The percentage text beside each bar is aria-hidden because the progress element already exposes its value. The single status line announces a map only when it finishes.

View code
.pb-dl {
  box-sizing: border-box;
  width: min(100%, 400px);
  padding: 20px;
  border: 1px solid #2a323d;
  border-radius: 18px;
  background: #161b22;
  color: #e6edf3;
}
.pb-dl__top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 8px;
}
.pb-dl__title {
  margin: 0;
  color: #e6edf3;
  font-size: 18px;
  line-height: 1.3;
}
.pb-dl__all {
  min-height: 44px;
  padding: 0 16px;
  border: 1px solid #2dd4bf;
  border-radius: 999px;
  background: transparent;
  color: #5eead4;
  font: inherit;
  font-size: 14px;
  font-weight: 700;
  cursor: pointer;
}
.pb-dl__all:hover {
  background: rgba(45, 212, 191, .12);
}
.pb-dl__all:disabled {
  border-color: #3a4552;
  color: #9aa7b4;
  cursor: default;
}
.pb-dl__all:focus-visible {
  outline: 3px solid #5eead4;
  outline-offset: 3px;
}
.pb-dl__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.pb-dl__row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 8px 12px;
  align-items: center;
  padding: 14px 0;
  border-top: 1px solid #2a323d;
}
.pb-dl__info {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  grid-column: 1 / -1;
  font-size: 15px;
}
.pb-dl__info label {
  font-weight: 600;
}
.pb-dl__size {
  color: #9aa7b4;
  font-size: 13px;
}
.pb-dl__bar {
  -webkit-appearance: none;
  appearance: none;
  display: block;
  width: 100%;
  height: 6px;
  border: 0;
  border-radius: 3px;
  background: #2a323d;
  overflow: hidden;
}
.pb-dl__bar::-webkit-progress-bar {
  border-radius: 3px;
  background: #2a323d;
}
.pb-dl__bar::-webkit-progress-value {
  border-radius: 3px;
  background: #2dd4bf;
}
.pb-dl__bar::-moz-progress-bar {
  border-radius: 3px;
  background: #2dd4bf;
}
.pb-dl__row.is-done .pb-dl__bar::-webkit-progress-value {
  background: #a3e635;
}
.pb-dl__row.is-done .pb-dl__bar::-moz-progress-bar {
  background: #a3e635;
}
.pb-dl__pct {
  min-width: 52px;
  color: #c3ccd5;
  font-size: 13px;
  font-variant-numeric: tabular-nums;
  text-align: right;
}
.pb-dl__row.is-done .pb-dl__pct {
  color: #bef264;
  font-weight: 700;
}
.pb-dl__status {
  min-height: 1.5em;
  margin: 6px 0 0;
  color: #c3ccd5;
  font-size: 14px;
}

Indeterminate bar that turns determinate

  • :indeterminate
  • removeAttribute
  • background-position

Syncing 1,204 photos

From Field Camera to your library

Syncing

A progress element with no value attribute is indeterminate. The moving segment is a one-color background image on the element itself, slid with background-position, while the Firefox bar is set to width 0 the way MDN does it. The button sets a value to finish the sync and removes it again to restart, which flips the :indeterminate match.

View code
.pb-ind {
  box-sizing: border-box;
  width: min(100%, 360px);
  padding: 22px;
  border-radius: 14px;
  background: #fffaf2;
  color: #2b2118;
  box-shadow: 0 1px 0 #eadfcd, 0 14px 30px rgba(84, 58, 24, .1);
}
.pb-ind__head {
  display: flex;
  align-items: center;
  gap: 14px;
  margin-bottom: 16px;
}
.pb-ind__icon {
  flex: none;
  width: 40px;
  height: 40px;
  border-radius: 12px;
  background: #f5e3cf;
  box-shadow: inset 0 0 0 9px #f5e3cf, inset 0 0 0 40px #b4441c;
}
.pb-ind__label {
  margin: 0;
  font-weight: 700;
}
.pb-ind__sub {
  margin: 2px 0 0;
  color: #6b5a48;
  font-size: 14px;
}
.pb-ind__bar {
  -webkit-appearance: none;
  appearance: none;
  display: block;
  width: 100%;
  height: 8px;
  border: 0;
  border-radius: 4px;
  background-color: #efe3d1;
  overflow: hidden;
}
.pb-ind__bar::-webkit-progress-bar {
  background: transparent;
}
.pb-ind__bar::-webkit-progress-value {
  border-radius: 4px;
  background: #b4441c;
}
.pb-ind__bar::-moz-progress-bar {
  border-radius: 4px;
  background: #b4441c;
}
.pb-ind__bar:indeterminate {
  background-image: linear-gradient(#b4441c, #b4441c);
  background-position: 50% 0;
  background-repeat: no-repeat;
  background-size: 30% 100%;
}
.pb-ind__bar:indeterminate::-moz-progress-bar {
  width: 0;
}
.pb-ind__toggle {
  min-height: 44px;
  margin-top: 18px;
  padding: 0 18px;
  border: 1px solid #b4441c;
  border-radius: 10px;
  background: #fff;
  color: #8f3311;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.pb-ind__toggle:hover {
  background: #fbeee4;
}
.pb-ind__toggle:focus-visible {
  outline: 3px solid #2b2118;
  outline-offset: 2px;
}
.pb-ind__status {
  min-height: 1.5em;
  margin: 10px 0 0;
  color: #6b5a48;
  font-size: 14px;
}
@keyframes pb-ind-slide {
  from {
    background-position: -43% 0;
  }
  to {
    background-position: 143% 0;
  }
}
@media (prefers-reduced-motion: no-preference) {
  .pb-ind__bar:indeterminate {
    animation: pb-ind-slide 1.4s ease-in-out infinite;
  }
}

Storage meter with low and high thresholds

  • meter
  • low / high / optimum
  • ::-webkit-meter-optimum-value
124 GB of 200 GB
124 GB of 200 GB

Plenty of space left

Disk usage is a measurement, not a task, so this is a meter. With optimum below low, the browser picks the region itself: green under 140 GB, amber up to 180 GB, red above. Each region has its own pseudo-element in Chrome and Safari and its own pseudo-class in Firefox, and the text line repeats the state so color is not the only cue.

View code
.pb-st {
  box-sizing: border-box;
  width: min(100%, 380px);
  padding: 22px;
  border: 1px solid #dfe5e1;
  border-radius: 16px;
  background: #fff;
  color: #18221d;
}
.pb-st__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 4px 12px;
  margin-bottom: 12px;
}
.pb-st__title {
  font-size: 17px;
  font-weight: 700;
}
.pb-st__used {
  color: #4f5c55;
  font-size: 14px;
  font-variant-numeric: tabular-nums;
}
.pb-st__used strong {
  color: #18221d;
}
.pb-st__meter {
  -webkit-appearance: none;
  appearance: none;
  display: block;
  width: 100%;
  height: 14px;
  border: 0;
  border-radius: 7px;
  background: #e7ece9;
  overflow: hidden;
}
.pb-st__meter::-webkit-meter-bar {
  height: 14px;
  border: 0;
  border-radius: 7px;
  background: #e7ece9;
}
.pb-st__meter::-webkit-meter-optimum-value {
  border-radius: 7px;
  background: #2f9e44;
}
.pb-st__meter::-webkit-meter-suboptimum-value {
  border-radius: 7px;
  background: #e67700;
}
.pb-st__meter::-webkit-meter-even-less-good-value {
  border-radius: 7px;
  background: #d9480f;
}
.pb-st__meter::-moz-meter-bar {
  border-radius: 7px;
  background: #2f9e44;
}
.pb-st__meter:-moz-meter-sub-optimum::-moz-meter-bar {
  background: #e67700;
}
.pb-st__meter:-moz-meter-sub-sub-optimum::-moz-meter-bar {
  background: #d9480f;
}
.pb-st__scale {
  position: relative;
  height: 22px;
  color: #4f5c55;
  font-size: 12px;
}
.pb-st__scale span {
  position: absolute;
  top: 4px;
  translate: -50% 0;
}
.pb-st__scale span::before {
  content: "";
  position: absolute;
  top: -10px;
  left: 50%;
  width: 1px;
  height: 6px;
  background: #8a968f;
}
.pb-st__low {
  left: 70%;
}
.pb-st__high {
  left: 90%;
}
.pb-st__state {
  margin: 6px 0 0;
  font-weight: 700;
}
.pb-st__state[data-state="ok"] {
  color: #237032;
}
.pb-st__state[data-state="warn"] {
  color: #9a4d00;
}
.pb-st__state[data-state="full"] {
  color: #b02a0c;
}
.pb-st__sim {
  display: grid;
  gap: 6px;
  margin-top: 16px;
  padding-top: 14px;
  border-top: 1px dashed #cfd8d3;
  color: #4f5c55;
  font-size: 13px;
}
.pb-st__range {
  width: 100%;
  min-height: 24px;
  margin: 0;
  accent-color: #237032;
}
.pb-st__range:focus-visible {
  outline: 3px solid #18221d;
  outline-offset: 3px;
}

Password strength meter

  • meter
  • aria-live
  • aria-describedby
Strength Empty
  • At least 12 characters
  • Upper and lower case letters
  • At least one number
  • At least one symbol

The meter runs from 0 to 4 with optimum at the top, so weak scores fall in the red region and a full score turns green. The verdict line is an aria-live region that only changes when the score changes, not on every keystroke, and the rule list is linked to the field with aria-describedby.

View code
.pb-pw {
  box-sizing: border-box;
  width: min(100%, 360px);
  padding: 24px;
  border-radius: 18px;
  background: #fff;
  color: #1d1b2c;
  box-shadow: 0 0 0 1px #e6e4f0, 0 16px 34px rgba(29, 27, 44, .08);
}
.pb-pw__label {
  display: block;
  margin-bottom: 8px;
  font-weight: 700;
}
.pb-pw__field {
  display: flex;
  border: 1.5px solid #a9a4c2;
  border-radius: 10px;
  background: #fff;
}
.pb-pw__field:focus-within {
  border-color: #5b3fd6;
  box-shadow: 0 0 0 3px rgba(91, 63, 214, .25);
}
.pb-pw__input {
  flex: 1;
  min-width: 0;
  min-height: 44px;
  padding: 0 12px;
  border: 0;
  border-radius: 10px;
  background: transparent;
  color: inherit;
  font: inherit;
  outline: none;
}
.pb-pw__show {
  min-width: 64px;
  margin: 4px;
  border: 0;
  border-radius: 7px;
  background: #efecfb;
  color: #4a2fc2;
  font: inherit;
  font-size: 14px;
  font-weight: 700;
  cursor: pointer;
}
.pb-pw__show:focus-visible {
  outline: 3px solid #5b3fd6;
  outline-offset: 1px;
}
.pb-pw__row {
  display: flex;
  justify-content: space-between;
  margin: 16px 0 6px;
  font-size: 14px;
}
.pb-pw__name {
  color: #5d5875;
}
.pb-pw__verdict {
  font-weight: 700;
}
.pb-pw__verdict[data-score="0"],
.pb-pw__verdict[data-score="1"] {
  color: #b42318;
}
.pb-pw__verdict[data-score="2"],
.pb-pw__verdict[data-score="3"] {
  color: #8a4b00;
}
.pb-pw__verdict[data-score="4"] {
  color: #1d7a3a;
}
.pb-pw__meter {
  -webkit-appearance: none;
  appearance: none;
  display: block;
  width: 100%;
  height: 8px;
  border: 0;
  border-radius: 4px;
  background: #eceaf3;
  overflow: hidden;
}
.pb-pw__meter::-webkit-meter-bar {
  height: 8px;
  border: 0;
  border-radius: 4px;
  background: #eceaf3;
}
.pb-pw__meter::-webkit-meter-optimum-value {
  border-radius: 4px;
  background: #1f9d55;
}
.pb-pw__meter::-webkit-meter-suboptimum-value {
  border-radius: 4px;
  background: #f59f00;
}
.pb-pw__meter::-webkit-meter-even-less-good-value {
  border-radius: 4px;
  background: #e5484d;
}
.pb-pw__meter::-moz-meter-bar {
  border-radius: 4px;
  background: #1f9d55;
}
.pb-pw__meter:-moz-meter-sub-optimum::-moz-meter-bar {
  background: #f59f00;
}
.pb-pw__meter:-moz-meter-sub-sub-optimum::-moz-meter-bar {
  background: #e5484d;
}
.pb-pw__rules {
  display: grid;
  gap: 6px;
  margin: 16px 0 0;
  padding: 0;
  list-style: none;
  color: #5d5875;
  font-size: 14px;
}
.pb-pw__rules li {
  position: relative;
  padding-left: 24px;
}
.pb-pw__rules li::before {
  content: "";
  position: absolute;
  top: .35em;
  left: 2px;
  width: 10px;
  height: 10px;
  border: 1.5px solid #a9a4c2;
  border-radius: 50%;
}
.pb-pw__rules li[data-met="true"] {
  color: #1d1b2c;
}
.pb-pw__rules li[data-met="true"]::before {
  top: .2em;
  left: 5px;
  width: 5px;
  height: 10px;
  border: solid #1d7a3a;
  border-width: 0 2px 2px 0;
  border-radius: 0;
  rotate: 45deg;
}

Custom bars, rings and steppers

When the design outgrows the native elements: rings, vertical fills, goal markers and step indicators. Every live bar carries role="progressbar" with its value attributes, and a status message speaks at milestones instead of on every tick.

Multi-step checkout stepper

  • aria-current="step"
  • CSS counters
  • role="status"

Shipping address

Step 2 of 4

A stepper is a list, not a progress bar, so it is an ordered list inside a labelled nav with aria-current="step" on the active item and hidden text on finished ones. The numbers come from a CSS counter, finished steps swap the number for a drawn check, and the connector lines fill as you go. A status line reads the new step after each click.

View code
.pb-sp {
  box-sizing: border-box;
  width: min(100%, 460px);
  padding: 24px 20px;
  border-radius: 16px;
  background: #fff;
  color: #14213d;
  box-shadow: 0 10px 28px rgba(20, 33, 61, .09);
}
.pb-sp__steps {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  margin: 0;
  padding: 0;
  list-style: none;
  counter-reset: pb-sp;
}
.pb-sp__step {
  position: relative;
  display: grid;
  justify-items: center;
  gap: 8px;
  counter-increment: pb-sp;
  color: #5a6478;
  font-size: 13px;
  text-align: center;
}
.pb-sp__step + .pb-sp__step::before {
  content: "";
  position: absolute;
  top: 15px;
  right: calc(50% + 20px);
  left: calc(-50% + 20px);
  height: 3px;
  border-radius: 2px;
  background: #dde2ec;
  background-image: linear-gradient(#e85d2a, #e85d2a);
  background-repeat: no-repeat;
  background-size: 0 100%;
}
.pb-sp__step.is-done + .pb-sp__step::before {
  background-size: 100% 100%;
}
.pb-sp__dot {
  position: relative;
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  border: 2px solid #c3cad8;
  border-radius: 50%;
  background: #fff;
  box-sizing: border-box;
  font-weight: 700;
}
.pb-sp__dot::before {
  content: counter(pb-sp);
}
.pb-sp__step[aria-current="step"] {
  color: #14213d;
  font-weight: 700;
}
.pb-sp__step[aria-current="step"] .pb-sp__dot {
  border-color: #e85d2a;
  color: #b8401a;
  box-shadow: 0 0 0 4px #fde4d9;
}
.pb-sp__step.is-done .pb-sp__dot {
  border-color: #e85d2a;
  background: #e85d2a;
}
.pb-sp__step.is-done .pb-sp__dot::before {
  content: "";
  width: 6px;
  height: 11px;
  margin-top: -3px;
  border: solid #fff;
  border-width: 0 2.5px 2.5px 0;
  rotate: 45deg;
}
.pb-sp__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.pb-sp__panel {
  margin: 22px 0 18px;
  padding: 16px;
  border-radius: 12px;
  background: #f5f7fb;
}
.pb-sp__heading {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 20px;
  font-weight: 600;
}
.pb-sp__count {
  margin: 4px 0 0;
  color: #5a6478;
  font-size: 14px;
}
.pb-sp__actions {
  display: flex;
  justify-content: space-between;
  gap: 10px;
}
.pb-sp__actions button {
  min-height: 44px;
  padding: 0 20px;
  border-radius: 10px;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.pb-sp__back {
  border: 1px solid #c3cad8;
  background: #fff;
  color: #14213d;
}
.pb-sp__next {
  border: 0;
  background: #14213d;
  color: #fff;
}
.pb-sp__actions button:disabled {
  opacity: .45;
  cursor: not-allowed;
}
.pb-sp__actions button:focus-visible {
  outline: 3px solid #e85d2a;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  .pb-sp__step + .pb-sp__step::before {
    transition: background-size .35s ease;
  }
}

Circular ring with stroke-dashoffset

  • SVG circle
  • stroke-dashoffset
  • role="progressbar"

Move goal

Log activity to close the ring.

The ring is an SVG circle whose dash is as long as its circumference, so shifting stroke-dashoffset by a fraction of it reveals that fraction. The wrapper carries role="progressbar" with aria-valuenow and a valuetext in kilocalories, because the SVG itself says nothing to assistive tech. Past the goal the ring stays full and the number keeps counting.

View code
.pb-rg {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 24px;
  width: min(100%, 440px);
  color: #f2f4f0;
}
.pb-rg__ring {
  position: relative;
  flex: none;
  width: 168px;
  height: 168px;
}
.pb-rg__ring svg {
  display: block;
  width: 100%;
  height: 100%;
  rotate: -90deg;
}
.pb-rg__ring circle {
  fill: none;
  stroke-width: 12;
}
.pb-rg__track {
  stroke: #2b302a;
}
.pb-rg__value {
  stroke: #c5f03c;
  stroke-linecap: round;
  stroke-dasharray: 326.73;
  stroke-dashoffset: calc(326.73 * (1 - var(--pb-rg-p)));
}
.pb-rg__ring.is-closed .pb-rg__value {
  stroke: #7ee08a;
}
.pb-rg__center {
  position: absolute;
  inset: 0;
  display: grid;
  place-content: center;
  text-align: center;
}
.pb-rg__num {
  font-size: 38px;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
.pb-rg__center span {
  margin-top: 4px;
  color: #b6bdb3;
  font-size: 13px;
}
.pb-rg__side {
  flex: 1 1 180px;
  min-width: 0;
}
.pb-rg__label {
  margin: 0;
  font-size: 20px;
  font-weight: 700;
}
.pb-rg__hint {
  margin: 4px 0 14px;
  color: #b6bdb3;
  font-size: 14px;
}
.pb-rg__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.pb-rg__actions button {
  min-height: 44px;
  padding: 0 16px;
  border-radius: 999px;
  font: inherit;
  font-size: 14px;
  font-weight: 700;
  cursor: pointer;
}
.pb-rg__add {
  border: 0;
  background: #c5f03c;
  color: #141712;
}
.pb-rg__reset {
  border: 1px solid #4a5247;
  background: transparent;
  color: #f2f4f0;
}
.pb-rg__actions button:focus-visible {
  outline: 3px solid #c5f03c;
  outline-offset: 3px;
}
.pb-rg__status {
  min-height: 1.5em;
  margin: 10px 0 0;
  color: #b6bdb3;
  font-size: 14px;
}
@media (prefers-reduced-motion: no-preference) {
  .pb-rg__value {
    transition: stroke-dashoffset .5s cubic-bezier(.3, .7, .2, 1), stroke .3s;
  }
}

Course completion card with a conic ring

  • conic-gradient hard stop
  • @property
  • aria-valuetext

Typography for interfaces

6 lessons, 48 min

  1. Choosing a type scale, done
  2. Line length and leading, done
  3. Pairing a serif with a sans, done
  4. Numbers in tables
  5. Variable fonts on the web
  6. Loading fonts without layout shift

The ring is a conic-gradient whose second stop sits at 0, which forces a hard edge at the current angle. Registering --pb-cc-p with @property as a percentage lets the angle transition. The ring reports lessons, not percent, through aria-valuetext, and each finished lesson carries hidden text so the list is not color-only.

View code
@property --pb-cc-p {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 0%;
}
.pb-cc {
  box-sizing: border-box;
  width: min(100%, 360px);
  padding: 22px;
  border: 1px solid #e8e1d4;
  border-radius: 20px;
  background: #fdfbf6;
  color: #26212e;
}
.pb-cc__top {
  display: flex;
  align-items: center;
  gap: 16px;
}
.pb-cc__ring {
  flex: none;
  display: grid;
  place-items: center;
  width: 76px;
  height: 76px;
  border-radius: 50%;
  background: conic-gradient(#6d3fc0 var(--pb-cc-p), #e9e2f5 0);
}
.pb-cc__pct {
  display: grid;
  place-items: center;
  width: 58px;
  height: 58px;
  border-radius: 50%;
  background: #fdfbf6;
  font-size: 16px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}
.pb-cc__title {
  margin: 0;
  color: #26212e;
  font-family: "Fraunces", Georgia, serif;
  font-size: 21px;
  font-weight: 600;
  line-height: 1.2;
}
.pb-cc__meta {
  margin: 4px 0 0;
  color: #645d6e;
  font-size: 14px;
}
.pb-cc__lessons {
  display: grid;
  gap: 2px;
  margin: 18px 0;
  padding: 0;
  list-style: none;
  font-size: 14px;
}
.pb-cc__lessons li {
  position: relative;
  padding: 7px 0 7px 30px;
  border-bottom: 1px solid #efe9de;
  color: #4c4555;
}
.pb-cc__lessons li::before {
  content: "";
  position: absolute;
  top: 9px;
  left: 2px;
  box-sizing: border-box;
  width: 18px;
  height: 18px;
  border: 1.5px solid #b9b0c6;
  border-radius: 50%;
}
.pb-cc__lessons li.is-done {
  color: #26212e;
}
.pb-cc__lessons li.is-done::before {
  border-color: #6d3fc0;
  background: #6d3fc0;
}
.pb-cc__lessons li.is-done::after {
  content: "";
  position: absolute;
  top: 12px;
  left: 9px;
  width: 4px;
  height: 8px;
  border: solid #fff;
  border-width: 0 2px 2px 0;
  rotate: 45deg;
}
.pb-cc__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.pb-cc__next {
  width: 100%;
  min-height: 46px;
  border: 0;
  border-radius: 12px;
  background: #26212e;
  color: #fff;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.pb-cc__next:hover {
  background: #3b3446;
}
.pb-cc__next:focus-visible {
  outline: 3px solid #6d3fc0;
  outline-offset: 3px;
}
.pb-cc__status {
  min-height: 1.4em;
  margin: 8px 0 0;
  color: #645d6e;
  font-size: 14px;
  text-align: center;
}
@media (prefers-reduced-motion: no-preference) {
  .pb-cc__ring {
    transition: --pb-cc-p .6s cubic-bezier(.3, .7, .2, 1);
  }
}

Dashboard KPIs against a goal

  • scaleX(min())
  • pace marker
  • aria-valuetext

Q4 targets

Day 62 of 92
  • Revenue$412k of $500k

    82%, ahead of pace

  • New trials1,840 of 1,500

    123%, goal passed

  • Enterprise deals9 of 20

    45%, behind pace

Expected by today

Each fill is scaleX(min(var(--pb-kpi-v), 1)), so a metric past its goal stays full and gets an outline instead of overflowing. A thin marker shows where the bar should be by today, and the line under each bar says ahead or behind in words. aria-valuenow stops at the goal while aria-valuetext carries the real figure.

View code
.pb-kpi {
  box-sizing: border-box;
  width: min(100%, 420px);
  padding: 22px;
  border-radius: 16px;
  background: #11131a;
  color: #eceef5;
  box-shadow: inset 0 0 0 1px #262a36;
}
.pb-kpi__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 6px;
}
.pb-kpi__title {
  margin: 0;
  color: #eceef5;
  font-size: 18px;
  line-height: 1.3;
}
.pb-kpi__day {
  color: #a3a9bb;
  font-size: 13px;
}
.pb-kpi__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.pb-kpi__item {
  padding: 14px 0 4px;
}
.pb-kpi__row {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 8px;
  font-size: 15px;
}
.pb-kpi__name {
  font-weight: 600;
}
.pb-kpi__val {
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}
.pb-kpi__val span {
  color: #a3a9bb;
  font-weight: 400;
}
.pb-kpi__track {
  position: relative;
  height: 10px;
  border-radius: 5px;
  background: #262a36;
}
.pb-kpi__fill {
  display: block;
  height: 100%;
  border-radius: 5px;
  background: #7aa2ff;
  transform: scaleX(min(var(--pb-kpi-v), 1));
  transform-origin: 0 50%;
}
.pb-kpi__track.is-over .pb-kpi__fill {
  background: #4ade80;
  box-shadow: 0 0 0 2px #11131a, 0 0 0 4px #4ade80;
}
.pb-kpi__track::after {
  content: "";
  position: absolute;
  top: -4px;
  bottom: -4px;
  left: calc(var(--pb-kpi-pace) * 100%);
  width: 2px;
  border-radius: 1px;
  background: #f5f6fa;
}
.pb-kpi__note {
  margin: 8px 0 0;
  font-size: 13px;
}
.pb-kpi__note.is-ahead {
  color: #a9c2ff;
}
.pb-kpi__note.is-over {
  color: #86efac;
}
.pb-kpi__note.is-behind {
  color: #fdba74;
}
.pb-kpi__legend {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 14px 0 0;
  padding-top: 12px;
  border-top: 1px solid #262a36;
  color: #a3a9bb;
  font-size: 13px;
}
.pb-kpi__key {
  width: 2px;
  height: 14px;
  border-radius: 1px;
  background: #f5f6fa;
}
@keyframes pb-kpi-grow {
  from {
    transform: scaleX(0);
  }
}
@media (prefers-reduced-motion: no-preference) {
  .pb-kpi__fill {
    animation: pb-kpi-grow .9s cubic-bezier(.3, .7, .2, 1) both;
  }
}

Vertical water tracker

  • scaleY
  • transform-origin: bottom
  • repeating-linear-gradient ticks

Water today

A vertical bar is the horizontal one with the axis swapped: the fill stretches to full height and scaleY grows it from transform-origin: bottom. The tick marks are a hard-stop repeating gradient laid over the fill. The bottle is the progressbar, so its value and valuetext change with every glass.

View code
.pb-vt {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 28px;
  width: min(100%, 400px);
  color: #0c2a3a;
}
.pb-vt__bottle {
  position: relative;
  flex: none;
  width: 84px;
  height: 220px;
  margin-top: 18px;
  border: 3px solid #0c2a3a;
  border-radius: 22px 22px 28px 28px;
  background: #e8f4fa;
  overflow: hidden;
}
.pb-vt__bottle::before {
  content: "";
  position: absolute;
  top: 0;
  left: 50%;
  width: 34px;
  height: 6px;
  border-radius: 0 0 6px 6px;
  background: #0c2a3a;
  translate: -50% 0;
  z-index: 1;
}
.pb-vt__water {
  position: absolute;
  inset: 0;
  background: #1c8ec4;
  transform: scaleY(var(--pb-vt-p));
  transform-origin: 50% 100%;
}
.pb-vt__ticks {
  position: absolute;
  inset: 0 auto 0 0;
  width: 16px;
  background: repeating-linear-gradient(to top, transparent 0 calc(25% - 2px), rgba(12, 42, 58, .55) calc(25% - 2px) 25%);
}
.pb-vt__side {
  flex: 1 1 0;
  min-width: 0;
  display: grid;
  justify-items: start;
  gap: 8px;
}
.pb-vt__label {
  margin: 0;
  color: #3b5a6a;
  font-size: 14px;
  font-weight: 600;
}
.pb-vt__amount {
  margin: 0 0 6px;
  color: #3b5a6a;
  font-size: 14px;
}
.pb-vt__amount strong {
  display: block;
  color: #0c2a3a;
  font-family: "Fraunces", Georgia, serif;
  font-size: 40px;
  line-height: 1.05;
  font-variant-numeric: tabular-nums;
}
.pb-vt__side button {
  min-height: 44px;
  padding: 0 14px;
  border-radius: 10px;
  font: inherit;
  font-size: 14px;
  font-weight: 700;
  cursor: pointer;
}
.pb-vt__add {
  border: 0;
  background: #0c2a3a;
  color: #fff;
}
.pb-vt__undo {
  border: 1px solid #9fb6c2;
  background: #fff;
  color: #0c2a3a;
}
.pb-vt__side button:focus-visible {
  outline: 3px solid #1c8ec4;
  outline-offset: 2px;
}
.pb-vt__status {
  min-height: 1.4em;
  margin: 0;
  color: #12693e;
  font-size: 14px;
  font-weight: 700;
}
@media (prefers-reduced-motion: no-preference) {
  .pb-vt__water {
    transition: transform .5s cubic-bezier(.3, .7, .2, 1);
  }
}

Skill level bars with text labels

  • mask hard stops
  • custom property
  • aria-hidden bar

Languages

  • Romanian Native
  • English C1, advanced
  • German B1, intermediate
  • Japanese A1, beginner

Levels use the CEFR scale, A1 to C2.

A skill level is a rating, not progress, so there is no progressbar role here. The level is written out in text and the six-segment bar is aria-hidden decoration. One element draws it: a hard-stop fill sized from --pb-sk-n, cut into segments by a repeating mask.

View code
.pb-sk {
  box-sizing: border-box;
  width: min(100%, 380px);
  padding: 24px;
  border-radius: 6px;
  background: #f6f1e7;
  color: #1f2a24;
  box-shadow: 0 1px 0 #e4dccb, 0 18px 36px rgba(60, 48, 28, .1);
}
.pb-sk__title {
  margin: 0 0 6px;
  color: #1f2a24;
  font-family: "Fraunces", Georgia, serif;
  font-size: 24px;
  font-weight: 600;
}
.pb-sk__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.pb-sk__item {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 6px 12px;
  padding: 12px 0;
  border-bottom: 1px solid #e2d9c6;
}
.pb-sk__lang {
  font-weight: 700;
}
.pb-sk__level {
  color: #59634f;
  font-size: 14px;
  text-align: right;
}
.pb-sk__bar {
  grid-column: 1 / -1;
  height: 8px;
  background: linear-gradient(to right, #1d5a43 calc(var(--pb-sk-n) / 6 * 100%), #d9cfbb 0);
  -webkit-mask: repeating-linear-gradient(to right, #000 0 calc(100% / 6 - 4px), transparent 0 calc(100% / 6));
  mask: repeating-linear-gradient(to right, #000 0 calc(100% / 6 - 4px), transparent 0 calc(100% / 6));
  -webkit-mask-size: calc(100% + 4px) 100%;
  mask-size: calc(100% + 4px) 100%;
}
.pb-sk__foot {
  margin: 12px 0 0;
  color: #59634f;
  font-size: 13px;
}

Animated, scroll-linked and CSS-only bars

Stripes, a button that fills while it works, a reading bar driven by scroll(), stories-style segments and a checklist that counts itself with :has(). All motion sits behind prefers-reduced-motion: no-preference, so the reduced version is a still bar that still makes sense.

Striped deploy bar with a pause button

  • hard-stop stripes
  • background-position
  • aria-pressed

Deploying web@4f2a9c1 to production

  1. Build finished in 1m 12s
  2. 412 tests passed
  3. Uploading assets, 38 of 59
  4. Health checks

The stripes are a 24px tile of hard-stop diagonal bands, and the loop moves background-position by exactly one tile, so the restart is invisible. Motion only runs under prefers-reduced-motion: no-preference, and a pause toggle covers anyone who still wants it still. The step list under the bar says what the percentage means.

View code
.pb-sd {
  box-sizing: border-box;
  width: min(100%, 400px);
  padding: 22px;
  border: 1px solid #ece4da;
  border-radius: 14px;
  background: #fff;
  color: #231a12;
}
.pb-sd__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 12px;
}
.pb-sd__title {
  margin: 0;
  font-size: 15px;
  font-weight: 600;
}
.pb-sd__title code {
  padding: 1px 5px;
  border-radius: 4px;
  background: #f4eee6;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 13px;
}
.pb-sd__pct {
  font-weight: 800;
  font-variant-numeric: tabular-nums;
}
.pb-sd__track {
  height: 14px;
  padding: 3px;
  border-radius: 999px;
  background: #f1ebe3;
}
.pb-sd__fill {
  display: block;
  width: 64%;
  height: 100%;
  border-radius: 999px;
  background-color: #d9480f;
  background-image: linear-gradient(45deg, rgba(255, 255, 255, .28) 25%, transparent 25% 50%, rgba(255, 255, 255, .28) 50% 75%, transparent 75%);
  background-size: 24px 24px;
}
.pb-sd__log {
  display: grid;
  gap: 6px;
  margin: 16px 0;
  padding: 0;
  list-style: none;
  color: #6b5d4f;
  font-size: 14px;
}
.pb-sd__log li {
  position: relative;
  padding-left: 22px;
}
.pb-sd__log li::before {
  content: "";
  position: absolute;
  top: .45em;
  left: 3px;
  width: 8px;
  height: 8px;
  border: 1.5px solid #b3a493;
  border-radius: 50%;
}
.pb-sd__log li.is-done {
  color: #231a12;
}
.pb-sd__log li.is-done::before {
  border-color: #2f7d4a;
  background: #2f7d4a;
}
.pb-sd__log li.is-now {
  color: #231a12;
  font-weight: 700;
}
.pb-sd__log li.is-now::before {
  border-color: #d9480f;
  background: #d9480f;
}
.pb-sd__pause {
  min-height: 40px;
  padding: 0 14px;
  border: 1px solid #cfc3b5;
  border-radius: 8px;
  background: #fff;
  color: #231a12;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.pb-sd__pause[aria-pressed="true"] {
  border-color: #231a12;
  background: #231a12;
  color: #fff;
}
.pb-sd__pause:focus-visible {
  outline: 3px solid #d9480f;
  outline-offset: 2px;
}
@keyframes pb-sd-stripes {
  from {
    background-position: 24px 0;
  }
  to {
    background-position: 0 0;
  }
}
@media (prefers-reduced-motion: no-preference) {
  .pb-sd__fill {
    animation: pb-sd-stripes .8s linear infinite;
  }
  .pb-sd.is-paused .pb-sd__fill {
    animation-play-state: paused;
  }
}
@media (prefers-reduced-motion: reduce) {
  .pb-sd__pause {
    display: none;
  }
}

Export button that fills while it works

  • aria-disabled
  • scaleX fill
  • role="status"

Orders, October

1,280 rows, 14 columns

The button becomes its own progress bar: a layer behind the label scales from 0 to 1 while the percentage counts up. It uses aria-disabled instead of disabled, so keyboard focus stays on it during the export. The percentage is aria-hidden, and the status line speaks only at the start and the end.

View code
.pb-lb {
  box-sizing: border-box;
  display: grid;
  gap: 16px;
  width: min(100%, 340px);
  padding: 20px;
  border-radius: 16px;
  background: #fff;
  color: #102a27;
  box-shadow: 0 0 0 1px #dbe7e4, 0 14px 30px rgba(16, 42, 39, .08);
}
.pb-lb__file {
  display: flex;
  align-items: center;
  gap: 14px;
}
.pb-lb__grid {
  flex: none;
  width: 40px;
  height: 46px;
  border: 2px solid #0f766e;
  border-radius: 6px;
  background: repeating-linear-gradient(to bottom, transparent 0 9px, #0f766e 9px 11px), #e6f4f1;
}
.pb-lb__title {
  margin: 0;
  font-weight: 700;
}
.pb-lb__meta {
  margin: 2px 0 0;
  color: #4b6461;
  font-size: 14px;
}
.pb-lb__btn {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  min-height: 48px;
  padding: 0 20px;
  border: 0;
  border-radius: 12px;
  background: #1e293b;
  color: #fff;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
  overflow: hidden;
  isolation: isolate;
}
.pb-lb__fill {
  position: absolute;
  inset: 0;
  z-index: -1;
  background: #0f766e;
  transform: scaleX(var(--pb-lb-p, 0));
  transform-origin: 0 50%;
}
.pb-lb__num {
  font-variant-numeric: tabular-nums;
}
.pb-lb__btn.is-busy {
  cursor: progress;
}
.pb-lb__btn.is-done .pb-lb__fill {
  transform: scaleX(0);
}
.pb-lb__btn:hover:not([aria-disabled="true"]) {
  background: #334155;
}
.pb-lb__btn:focus-visible {
  outline: 3px solid #0f766e;
  outline-offset: 3px;
}
.pb-lb__status {
  min-height: 1.4em;
  margin: 0;
  color: #4b6461;
  font-size: 14px;
  text-align: center;
}
@media (prefers-reduced-motion: no-preference) {
  .pb-lb__fill {
    transition: transform .22s linear;
  }
}

Reading progress driven by scroll()

  • animation-timeline: scroll()
  • @supports
  • position: sticky
Crumb Quarterly 4 min read

Four in the morning at a small bakery

By Ilinca Moraru

The lights go on before the street does. Mara unlocks the back door at ten to four, hangs her coat on the same nail as always and checks the dough that has been proofing in the walk-in since last night.

Forty loaves of rye, sixty baguettes, two trays of cardamom buns. The numbers barely change from week to week, which is the point. A neighbourhood bakery runs on people knowing exactly what will be on the shelf.

The oven takes forty minutes to reach temperature. In that time the bench is floured, the baskets are lined up by size and the radio goes on, quietly, because the flat upstairs is still asleep.

Shaping is the part nobody sees. Each baguette gets three folds and a roll, and Mara can tell by the feel of the surface whether the dough needs another ten minutes or is already a little past its best.

By half past five the first loaves are out and cooling on wire racks by the window. The crust crackles as it shrinks, a sound bakers call singing, and it carries all the way to the front of the shop.

The first customer is always the same man from the bus depot. He buys two rolls, pays in coins and never asks what is fresh, because everything is.

At seven the shutters go up. The shelves are full, the bench is scraped clean, and the next batch of dough is already resting in the cold for tomorrow.

End of article

Scroll the article. The bar under the sticky header is a scaleX keyframe tied to scroll(), which finds the nearest scrolling ancestor, here the article frame. It only attaches inside @supports and prefers-reduced-motion: no-preference, so elsewhere the bar stays hidden and the reading-time label carries on alone. The bar is aria-hidden because it adds nothing a scrollbar does not already tell.

View code
.pb-rd {
  position: relative;
  height: 100%;
  overflow-y: auto;
  background: #fbf8f2;
  color: #2a241d;
  overscroll-behavior: contain;
}
.pb-rd:focus-visible {
  outline: 3px solid #a4380f;
  outline-offset: -3px;
}
.pb-rd__head {
  position: sticky;
  top: 0;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 12px 20px;
  border-bottom: 1px solid #e6dfd2;
  background: #fbf8f2;
}
.pb-rd__brand {
  font-family: "Fraunces", Georgia, serif;
  font-size: 17px;
  font-weight: 700;
}
.pb-rd__time {
  color: #6c6153;
  font-size: 13px;
}
.pb-rd__bar {
  display: none;
  position: absolute;
  right: 0;
  bottom: -2px;
  left: 0;
  height: 3px;
  background: #a4380f;
  transform: scaleX(0);
  transform-origin: 0 50%;
}
.pb-rd__body {
  padding: 22px 22px 40px;
  font-size: 15px;
  line-height: 1.65;
}
.pb-rd__title {
  margin: 0;
  color: #2a241d;
  font-family: "Fraunces", Georgia, serif;
  font-size: 27px;
  font-weight: 600;
  line-height: 1.15;
}
.pb-rd__by {
  margin: 8px 0 18px;
  color: #6c6153;
  font-size: 13px;
}
.pb-rd__body p {
  margin: 0 0 14px;
}
.pb-rd__body .pb-rd__end {
  margin-top: 24px;
  color: #6c6153;
  font-size: 13px;
  text-align: center;
}
@keyframes pb-rd-grow {
  to {
    transform: scaleX(1);
  }
}
@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    .pb-rd__bar {
      display: block;
      animation: pb-rd-grow linear both;
      animation-timeline: scroll();
    }
  }
}

Stories-style segmented progress

  • animationend
  • animation-play-state
  • aria-pressed
northbound.trips
A deep blue fjord between grey granite cliffs, seen from a crowded rock ledge
Day 1. The ledge was busier than the photos suggest.

Each story has a segment, and the active one fills with a five-second keyframe. When it ends, animationend moves to the next story, so CSS sets the pace and the script only reacts. The pause button holds the fill with animation-play-state, and an IntersectionObserver does the same while the frame is off screen. Under reduced motion nothing fills or advances on its own, and the tap zones still step through.

View code
.pb-so {
  position: relative;
  height: 100%;
  overflow: hidden;
  background: #0b0d10;
  color: #fff;
}
.pb-so__segs {
  position: absolute;
  top: 10px;
  right: 12px;
  left: 12px;
  z-index: 3;
  display: flex;
  gap: 4px;
}
.pb-so__seg {
  flex: 1;
  height: 3px;
  border-radius: 2px;
  background: rgba(255, 255, 255, .35);
  overflow: hidden;
}
.pb-so__seg span {
  display: block;
  height: 100%;
  background: #fff;
  transform: scaleX(0);
  transform-origin: 0 50%;
}
.pb-so__seg.is-seen span,
.pb-so__seg.is-now span {
  transform: scaleX(1);
}
.pb-so__top {
  position: absolute;
  top: 20px;
  right: 8px;
  left: 14px;
  z-index: 3;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.pb-so__who {
  font-size: 14px;
  font-weight: 700;
  text-shadow: 0 1px 3px rgba(0, 0, 0, .6);
}
.pb-so__pause {
  position: relative;
  width: 44px;
  height: 44px;
  border: 0;
  border-radius: 50%;
  background: rgba(0, 0, 0, .35);
  cursor: pointer;
}
.pb-so__pause::before,
.pb-so__pause::after {
  content: "";
  position: absolute;
  top: 15px;
  width: 4px;
  height: 14px;
  border-radius: 1px;
  background: #fff;
}
.pb-so__pause::before {
  left: 16px;
}
.pb-so__pause::after {
  left: 24px;
}
.pb-so__pause[aria-pressed="true"]::before {
  left: 17px;
  width: 0;
  height: 0;
  border-style: solid;
  border-width: 7px 0 7px 12px;
  border-color: transparent transparent transparent #fff;
  border-radius: 0;
  background: none;
}
.pb-so__pause[aria-pressed="true"]::after {
  display: none;
}
.pb-so__pause:focus-visible,
.pb-so__prev:focus-visible,
.pb-so__next:focus-visible {
  outline: 3px solid #fff;
  outline-offset: -5px;
}
.pb-so__slide {
  position: absolute;
  inset: 0;
  margin: 0;
}
.pb-so__slide[hidden] {
  display: none;
}
.pb-so__slide img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.pb-so__slide figcaption {
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  padding: 18px 18px 26px;
  background: rgba(8, 10, 14, .72);
  font-size: 15px;
  line-height: 1.45;
}
.pb-so__prev,
.pb-so__next {
  position: absolute;
  top: 72px;
  bottom: 90px;
  z-index: 2;
  border: 0;
  background: transparent;
  cursor: pointer;
}
.pb-so__prev {
  left: 0;
  width: 35%;
}
.pb-so__next {
  right: 0;
  width: 65%;
}
.pb-so__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
@keyframes pb-so-fill {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}
@media (prefers-reduced-motion: no-preference) {
  .pb-so__seg.is-now span {
    animation: pb-so-fill 5s linear both;
  }
  .pb-so.is-paused .pb-so__seg.is-now span,
  .pb-so.is-away .pb-so__seg.is-now span {
    animation-play-state: paused;
  }
}
@media (prefers-reduced-motion: reduce) {
  .pb-so__pause {
    visibility: hidden;
  }
}

Onboarding checklist that counts itself

  • :has(:checked)
  • CSS counters
  • calc() sum

Set up your workspace

No script at all. Each :has(#id:checked) rule flips one custom property from 0 to 1, the fill scales by their sum divided by four, and a CSS counter incremented by every checked box writes the count in generated text. The checkboxes carry the real state, so the bar itself is aria-hidden.

View code
.pb-ck {
  --pb-ck-a: 0;
  --pb-ck-b: 0;
  --pb-ck-c: 0;
  --pb-ck-d: 0;
  box-sizing: border-box;
  width: min(100%, 360px);
  padding: 22px;
  border-radius: 18px;
  background: #fff;
  color: #1c1f2a;
  box-shadow: 0 0 0 1px #e3e5ed, 0 14px 30px rgba(28, 31, 42, .08);
  counter-reset: pb-ck-done;
}
.pb-ck:has(#pb-ck-1:checked) {
  --pb-ck-a: 1;
}
.pb-ck:has(#pb-ck-2:checked) {
  --pb-ck-b: 1;
}
.pb-ck:has(#pb-ck-3:checked) {
  --pb-ck-c: 1;
}
.pb-ck:has(#pb-ck-4:checked) {
  --pb-ck-d: 1;
}
.pb-ck__title {
  margin: 0 0 10px;
  color: #1c1f2a;
  font-size: 18px;
  line-height: 1.3;
}
.pb-ck__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.pb-ck__list li {
  display: flex;
  align-items: center;
  gap: 12px;
  border-bottom: 1px solid #eef0f5;
}
.pb-ck__box {
  -webkit-appearance: none;
  appearance: none;
  position: relative;
  flex: none;
  width: 24px;
  height: 24px;
  margin: 0;
  border: 2px solid #9aa1b5;
  border-radius: 7px;
  background: #fff;
  cursor: pointer;
}
.pb-ck__box:checked {
  border-color: #d6336c;
  background: #d6336c;
  counter-increment: pb-ck-done;
}
.pb-ck__box:checked::after {
  content: "";
  position: absolute;
  top: 2px;
  left: 6px;
  width: 6px;
  height: 11px;
  border: solid #fff;
  border-width: 0 2.5px 2.5px 0;
  rotate: 45deg;
}
.pb-ck__box:focus-visible {
  outline: 3px solid #1c1f2a;
  outline-offset: 2px;
}
.pb-ck__list label {
  flex: 1;
  padding: 12px 0;
  cursor: pointer;
}
.pb-ck__box:checked + label {
  color: #5e6477;
  text-decoration: line-through;
  text-decoration-color: #b7bccb;
}
.pb-ck__foot {
  margin-top: 16px;
}
.pb-ck__count {
  margin: 0 0 8px;
  color: #3f4558;
  font-size: 14px;
  font-weight: 700;
}
.pb-ck__count::before {
  content: counter(pb-ck-done) " of 4 done";
}
.pb-ck:not(:has(.pb-ck__box:not(:checked))) .pb-ck__count::before {
  content: "All done, your workspace is ready";
  color: #a61e4d;
}
.pb-ck__track {
  display: block;
  height: 8px;
  border-radius: 4px;
  background: #f1e6ec;
  overflow: hidden;
}
.pb-ck__fill {
  display: block;
  height: 100%;
  border-radius: 4px;
  background: #d6336c;
  transform: scaleX(calc((var(--pb-ck-a) + var(--pb-ck-b) + var(--pb-ck-c) + var(--pb-ck-d)) / 4));
  transform-origin: 0 50%;
}
@media (prefers-reduced-motion: no-preference) {
  .pb-ck__fill {
    transition: transform .35s cubic-bezier(.3, .7, .2, 1);
  }
}

How do you make a CSS progress bar?

Reach for the native progress element when the bar reports real task progress. If the design needs full visual control, build it from a div track with a fill inside.

For reading progress on long pages, a scroll-linked bar built on animation-timeline works without any script.

MethodStyling controlScreen reader supportNeeds JavaScript
Progress elementLimited, differs by engineBuilt inTo update value
Div track and fillFullManualTo update width or variable
Scroll-linked barFullManualNo

Default to the progress element. MDN lists it as Baseline widely available, and as available across browsers since July 2015, and its implicit ARIA role is progressbar, so support and semantics both come free.

Switch to the div bar when the fill needs a gradient or a label drawn right on it. Also switch if you’d rather not write the same styling twice for two browser engines.

Either way, you’ll write a few lines of JavaScript to push the current value into the markup. The scroll-linked bar skips that, but it only tracks scroll position.

Using and styling the HTML progress element

The progress element draws its bar from a value and a max attribute. Styling it is the annoying part, since Chrome, Safari and Firefox build the element differently and each wants its own selector.

  1. Reset native styling with appearance: none on the progress element.
  2. Set width, height and the track color on progress itself (this styles the track in Firefox).
  3. Style the track in Chrome and Safari with ::-webkit-progress-bar.
  4. Style the fill in Chrome and Safari with ::-webkit-progress-value.
  5. Style the fill in Firefox with ::-moz-progress-bar.
  6. Test the indeterminate state by removing the value attribute.

Value, max and the indeterminate state

Leave off the value attribute and the progress element goes indeterminate, meaning something is running and nobody knows how long it will take.

When you do set it, value is a number from 0 to max, or from 0 to 1 when max is omitted. The max attribute is a floating point number above 0 and defaults to 1. A min attribute isn’t allowed. The minimum is always 0.

The :indeterminate pseudo-class matches the value-less state. To turn a determinate bar back into an indeterminate one, remove the attribute with removeAttribute(‘value’), according to MDN.

MDN’s own ::-moz-progress-bar example sets the width of :indeterminate bars to 0, which is a handy way to drop the default moving fill and run your own loop.

Styling the track and fill per browser engine

In Chrome and Safari, ::-webkit-progress-bar styles the track and ::-webkit-progress-value styles the fill, both sitting inside a ::-webkit-progress-inner-element wrapper.

Firefox is simpler. The progress element itself is the track, and ::-moz-progress-bar styles the fill.

Sources disagree on the Firefox name. Some tutorials write ::-moz-progress-value, but MDN documents only ::-moz-progress-bar and flags it as non-standard and experimental.

Treat the split as a cross-browser compatibility test case and keep each vendor selector in its own rule. A selector list that contains a pseudo-element the engine does not recognize is dropped whole, so one combined rule styles neither browser.

Removing native appearance and rounding corners

appearance: none strips the operating system’s bar so your background, border and size rules apply. Without it, the browser keeps painting the native control.

Chrome and Safari treat track and fill as separate boxes, so border-radius has to go on progress, ::-webkit-progress-bar and ::-webkit-progress-value. Firefox needs the radius on the progress element, and on ::-moz-progress-bar too if the fill should keep rounded ends.

For a pill shape, a border radius generator lets you preview the corners before you paste the value into all three rules.

How to build a CSS progress bar from div elements

Nest a fill element inside a track element, then give the track overflow: hidden and a border radius. The fill’s size can come from a plain width percentage or from a custom property that CSS reads back with var().

<div class="bar" role="progressbar" aria-label="Upload" aria-valuenow="40">
<div class="bar-fill" style="--p: 0.4"></div>
</div>

.bar { height: 10px; background: #e2e8f0; border-radius: 999px; overflow: hidden; }
.bar-fill { height: 100%; background: #2563eb; transform: scaleX(var(--p)); transform-origin: left; transition: transform 0.3s ease; }

A width percentage is the simplest option, one inline style or class per value. A custom property gets tidier as the component grows, since a script sets –p once and CSS reads it with var(–p) anywhere inside. The code above uses transform: scaleX(), which gives the same visual result at a lower animation cost.

web.dev’s animation guide steers authors toward transform and opacity because they stay on the compositing stage, and away from any property that triggers layout or paint.

Its demo has numbers for this. Animating top and left dropped 50% of frames, while the transform version dropped 1%. Width belongs to the same layout-triggering group as top and left, so scaleX wins when the bar updates often.

The catch is distortion. scaleX stretches everything inside the fill, including text and rounded ends, so keep labels in a sibling element and let the track’s overflow: hidden shape the ends.

Percentage text works best as a separate element positioned over the track or placed beside it. Coloring the bar by value, low versus high for example, needs a class switched in the same script step that updates the bar.

A linear-gradient fill and an inset box-shadow on the track add depth, and a gradient generator speeds up picking the color stops.

How to make a CSS progress bar accessible

A native progress element already exposes the progressbar role, so all it needs from you is an accessible name. A div bar gets nothing for free. You add the role and the value attributes yourself, plus a name.

MDN recommends the native element over the role for exactly that reason. Every behavior a div bar needs has to be rebuilt by hand with ARIA attributes, with script and CSS to match.

  • role=”progressbar” goes on the track element
  • aria-valuenow holds the current value and changes with every visual update, but it’s left out when the state is indeterminate
  • aria-valuemin and aria-valuemax default to 0 and 100, so set them only when your range differs
  • aria-valuetext carries a readable phrase such as “Step 2 of 5” when a percentage would mislead
  • aria-label or aria-labelledby supplies the accessible name, and it’s required

On the native element, a label element or aria-label does the naming. Text between the progress tags doesn’t count, MDN notes, since it’s only a fallback for old browsers.

For a section that loads in the background, point aria-describedby at the bar and set aria-busy=”true” on the section until it finishes.

For long tasks, announce milestones such as halfway and done through a status message (role=”status”). WCAG 2.2 Success Criterion 4.1.3 Status Messages (Level AA) asks for exactly that: updates that assistive technology can present without moving focus. Rewriting aria-valuetext on every tick only adds noise.

Contrast is the other half of it. W3C’s WCAG 2.2 Understanding docs set a 3:1 minimum color contrast ratio for the visual parts of an interface component and its states (Success Criterion 1.4.11, Level AA).

In a progress bar, that covers the fill against the track and the track against the page. The criterion exempts components whose look the browser sets and the author never changes, so an unstyled progress element passes by default and a restyled one becomes your job to check.

Probably the most common failure on custom bars is a styled div with no role, which a screen reader never announces as a progress bar. And anything nested inside a role=”progressbar” element is flattened to plain text, so keep links and headings out of it.

Animating a CSS progress bar: transitions, stripes and indeterminate loops

Transitions handle value changes. Keyframes handle stripes and indeterminate loops, and a prefers-reduced-motion rule switches the decorative motion off.

For value changes, put a transition on the fill, around 300 ms with an ease-out curve, so a jump from 40% to 70% glides instead of snapping.

Stripes come from a repeating-linear-gradient at 45 degrees on the fill, with a keyframe animation that moves background-position by exactly one stripe width per loop. An indeterminate loop is a segment about 40% as wide as the track, translated across it on an infinite linear animation.

Both loops are plain CSS keyframes set to repeat. A stripe loop restarts cleanly only when the keyframe offset equals the size of one stripe cycle, and any other value shows a jump at every restart.

.bar-loop { position: relative; height: 10px; background: #e2e8f0; overflow: hidden; }
.bar-loop::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 40%; background: #2563eb; animation: slide 1.2s linear infinite; }
@keyframes slide { from { transform: translateX(-100%); } to { transform: translateX(250%); } }

For a fill that plays once and stays full, add animation-fill-mode: forwards. Without it the bar snaps back to its starting state the moment the animation ends.

The prefers-reduced-motion query reports an operating system setting with two values, no-preference and reduce, according to web.dev. Vestibular reactions to motion range from dizziness and nausea to migraine headaches, so stripes and endless loops are the first things to remove.

Put the rule inside a media query for the reduce case and leave the static fill in place.

@media (prefers-reduced-motion: reduce) {
  .bar-fill, .bar-loop::before { animation: none; }
}

An indeterminate bar with its loop removed shows nothing moving, so pair it with a visible text label such as Loading.

If script listens for animationend, shorten the duration to 1ms instead of using animation: none. That is the same trade-off web.dev’s forced-reduction snippet makes, because pages that wait for the event break otherwise.

Can a CSS progress bar track page scroll without JavaScript?

Yes, in browsers that support scroll-driven animations. Attach a scaleX keyframe to animation-timeline: scroll() on a fixed bar and it fills as the page scrolls, with no scroll listener.

@keyframes grow-progress { to { transform: scaleX(1); } }

.reading-bar {
  position: fixed; top: 0; left: 0; width: 100%; height: 6px;
  background: #2563eb;
  transform: scaleX(0);
  transform-origin: 0 50%;
}

@supports (animation-timeline: scroll()) {
  .reading-bar {
    animation: grow-progress auto linear;
    animation-timeline: scroll();
  }
}

The scroll() function takes two optional arguments, per Chrome for Developers. The scroller is nearest (default), root or self, and the axis is block (default), inline, x or y.

Leave animation-duration as auto, which is what omitting it gives you, because scroll position now drives the animation instead of time. Write animation-timeline after the animation shorthand too, since the shorthand resets it to auto (MDN).

The lookup is the tricky bit. Anonymous lookups follow the containing block chain, so an absolutely positioned bar can skip a scroller that is not its containing block.

A named timeline fixes the lookup problem. Set scroll-timeline-name on the scroller and reference the same name in animation-timeline.

Support has only just closed the gap. Chrome and Edge start at version 115, named in Chrome for Developers’ guide published May 5, 2023, and Safari 26, released in September 2025, has it too.

Firefox was the holdout. MDN’s Firefox 159 release notes say scroll-driven animations are now enabled by default, and Firefox 159 is scheduled for October 27, 2026. MDN’s status still reads Limited availability, because the Baseline data has not caught up with that release yet, and plenty of visitors will run older versions for a while.

Some summaries date Chrome support to 2025. Chrome for Developers published its scroll-driven animations guide on May 5, 2023 and already names Chrome 115 as the first version, so 2023 is the date to trust for Chrome.

Unsupported browsers need a quiet failure. The base rule above starts the bar at scaleX(0) and the animation attaches only inside @supports, so older browsers show no bar instead of a permanently full one.

view() works the same way for reveal effects but tracks an element’s visibility inside its scroller instead of the scroll position. More patterns live in this guide to CSS scroll effects.

Circular, vertical and step progress bars

Rings are the odd one out, since they run a conic-gradient() from a custom property instead of a plain fill. Vertical bars just swap the fill axis, and step bars use separate segments or a scroll-driven fill.

A ring needs a registered custom property so the browser can animate the angle.

@property --p { syntax: "<percentage>"; inherits: false; initial-value: 0%; }

.ring { position: relative; width: 96px; height: 96px; border-radius: 50%;
  background: conic-gradient(#2563eb var(--p), #e2e8f0 0);
  transition: --p 0.4s ease; }
.ring::after { content: ""; position: absolute; inset: 10px; border-radius: 50%; background: #fff; }

MDN marks @property as Baseline newly available since July 2024, and its own example animates a –progress value from 25% to 100% over 2.5 seconds to drive a gradient bar. conic-gradient() has been available across browsers since November 2020, per MDN.

The 0 on the second color stop forces a hard edge. A stop positioned lower than an earlier stop is raised to that earlier stop’s position, so both colors meet at a single point. The ::after disc punches the hole, so give it the page background color.

MDN warns that browsers pass no information about background images to assistive technology. A ring therefore needs the same role and value attributes as a div bar on its wrapper, or screen reader users get nothing.

Vertical bars follow the same pattern with the axis swapped. Give the track a fixed height, stretch the fill to the full height and scale it with transform: scaleY(var(–p)) from transform-origin: bottom.

For the native element, MDN’s guide to vertical form controls covers the writing-mode route.

Step bars drop the continuous fill. One element per step carries a done class, and the segments sit in a flex row with a small gap.

Chrome for Developers documents a scroll-driven version, a carousel indicator that starts at 1 divided by the number of images (33% for three) and reaches full width when the last image is in view. A named scroll timeline on the carousel’s scroll container drives it.

Pick segments when the steps are few and fixed. A stepper component fits better when each step needs its own label.

Limits of a CSS-only progress bar

CSS can’t measure anything. A CSS-only progress bar just displays a number that something else supplies.

It has no data source, for one. CSS cannot read upload, download or task state, so a script has to write the value or the –p variable.

The semantics go stale too. MDN says aria-valuenow should be provided and updated unless the bar is indeterminate, and CSS cannot write attributes.

Scroll-driven bars depend on animation-timeline, which only reached all three engines with Firefox 159, so older browsers get the fallback, which is no bar. And a keyframe fill with a fixed duration finishes whether or not the work does, which makes it fake progress.

The stale semantics have a direct consequence for the scroll-driven bar. With role=”progressbar” and no current value it reads as indeterminate, so treat it as decoration and mark it aria-hidden=”true” instead.

A timed fill is fine for a splash screen you control. For anything with real work behind it, it reports progress that does not exist.

SituationBetter choiceReason
Known percentageProgress element or div barValue comes from real data
Unknown durationIndeterminate loop or loaderNo number to claim
Page layout still loadingPlaceholder blocksShows structure, not progress
Position in a long articleScroll-driven barScroll position is the data

When the wait has no measurable length, drop the percentage. Skeleton screens and low-quality image previews are common ways to make waits feel faster.

A CSS loader signals activity without claiming a number, which makes it the honest choice for an indeterminate wait.

A native progress element and a few lines of script cover most real cases. Anything fancier needs a reason.

CSS Progress Bar FAQ

How do you update the value of a progress bar with JavaScript?

Assign a number to the value property of the progress element, for example progress.value = 70, within the 0 to max range.

On a div bar, write the fraction to the –p custom property and the matching number to aria-valuenow in the same step.

Can the progress element display the percentage as text?

No. Text placed between the progress tags is only a fallback for old browsers, according to MDN, and it does not work as an accessible label either.

Put the number in a sibling span and update it together with the value.

How is the progress element different from the meter element?

Progress reports how far a task has gone, while meter shows a measurement inside a known range, such as disk usage.

Progress also has a fixed minimum of 0 and does not allow a min attribute, per MDN. Meter takes min, max, low, high and optimum, and the browser colors the bar by which region the value falls in.

Does Bootstrap have a ready-made progress bar?

Yes. Bootstrap ships a track and fill component with striped and animated modifiers.

This guide to the Bootstrap progress bar shows the markup and the class names.

How do you style a progress bar in Tailwind CSS?

Compose a track and fill from utility classes, because Tailwind has no progress component of its own.

For the native element, arbitrary variants such as [&::-webkit-progress-value] reach the vendor pseudo-elements, as these Tailwind progress bar examples show.

Which CSS Progress Bar Method to Use First

A CSS progress bar should start as the native progress element, with a div-based fill only where the design outgrows the vendor pseudo-elements.

The order that costs the least maintenance begins with the native progress element and its appearance reset. Next comes a div track and fill with hand-written ARIA, and last a scroll-linked fill behind an @supports check.

That sequence follows browser support. The native element has been available across browsers since July 2015, which is more than eleven years of coverage, while animation-timeline only reached its third engine in October 2026.

The trade-off sits with the div bar. It gives full styling control, and in return the role, the current value and the accessible name become the author’s job.

Firefox 159, scheduled for October 27, 2026, enables scroll-driven animations by default, so all three engines now have them. Keep the fallback for the scroll-linked bar anyway, since older browser versions stay in use for months. That position was checked on October 27, 2026.