Skip to content

feat(Progress): gradient bars and a segmented variant - #1181

Merged
mrholek merged 11 commits into
v6-devfrom
feat/progress-gradient-segments-v6
Sep 6, 2026
Merged

feat(Progress): gradient bars and a segmented variant#1181
mrholek merged 11 commits into
v6-devfrom
feat/progress-gradient-segments-v6

Conversation

@mrholek

@mrholek mrholek commented Sep 5, 2026

Copy link
Copy Markdown
Member

What changes

Gradient bars. --cui-progress-bar-bg-image (default none) is painted by .progress-bar over its colour, so a gradient is one variable set on the .progress or on a single bar. Stripes get their own tokens — --cui-progress-bar-stripes and --cui-progress-bar-stripe-color — and .progress-bar-striped layers the pattern over the image; the progress-bar-stripes keyframes shift only the first background layer.

.progress-segmented. A seven-layer mask (a segment box composited with intersect over two crossed bars and four hard-edged corner circles) cuts a .progress or .progress-stacked into --cui-progress-segments rounded segments (40 by default) separated by --cui-progress-segment-gap, with corners from --cui-progress-segment-border-radius (--cui-radius-2 by default, 0px for square). The tile is (100% + gap) / n, so segments scale with the track and the pitch is known to the stylesheet; a corner wider than the segment is cut off at the gap. Everything under the mask — transition, theme, gradient, stripes — behaves as before.

Whole segments. .progress-segmented > .progress-bar reads --cui-progress-segments-filled and ends on that segment's far edge. The new Progress plugin sets it: initialized on every .progress-segmented, it reads the bar's aria-valuenow / aria-valuemin / aria-valuemax, floors the covered segments, and follows the attributes through a MutationObserver. A bar without aria-valuenow is left alone, so a page can set the variable by hand or keep an inline width with a partial segment.

Fewer segments on narrow tracks. --cui-progress-segment-min-width (.375rem) is the width a segment may not drop below. The plugin watches the track with a ResizeObserver and writes the count that fits to --cui-progress-segments-fit, which the mask and the bar read with the token as fallback — 13 of the default 40 on a 150 px track, all 40 at 520 px. The token stays the ceiling.

The radius feeds the mask's calc(), so it has to be a length (0px, not 0); documented. Chromium rejects math functions that mix a length with a percentage in a radial-gradient size, which is why the corner clamp is a composited box rather than min().

Docs

components/progress: Gradients and Segmented sections, Usage section for the plugin, segment tokens under Customizing. Migration: entries under Progress.

Verification

  • css-lint, css-test (116 specs), js-lint, tsc, progress.spec.js (19 specs incl. ResizeObserver), full pre-push gate incl. docs-build.
  • bundlewatch: CSS ceilings +.5 kB, JS ceilings +1 kB (reasons in the commits).
  • Chromium and Firefox renders of the built CSS + bundle at 150 / 330 / 520 px tracks, 4 px height, corners at default, 0px and radius-5.

Not in this PR

React and Vue: CProgressBar knows its value, so it can write the variables itself; port follows once this lands.

Add `--cui-progress-bar-bg-image` (default `none`), painted by `.progress-bar`
over its colour, so a gradient is a single variable set on the `.progress` or
on one bar. The stripes move to their own tokens, `--cui-progress-bar-stripes`
and `--cui-progress-bar-stripe-color`, and `.progress-bar-striped` layers the
pattern over the image with a per-layer `background-size`. The stripe
keyframes shift only the first background layer, so the image below does not
scroll with the animation.

Add `.progress-segmented`: a three-layer mask (two caps, one body) tiled with
`space` along the track that cuts a `.progress` or `.progress-stacked` into
whole rounded pills sized by `--cui-progress-segment-width` and
`--cui-progress-segment-gap`; the gaps absorb the remainder of the width. The
body layer repeats horizontally only — with `space` on both axes a track at
least twice the body's height tiled it vertically and squared the pills. The
variant zeroes the track radius and shadow, on the nested `.progress` of a
stack too, since the pill outline replaces both.

Docs: Gradients and Segmented sections, segmented tokens under Customizing,
migration entries. sass-true covers the token maps.
@coveralls

coveralls commented Sep 5, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 34000013115

Coverage increased (+0.006%) to 93.431%

Details

  • Coverage increased (+0.006%) from the base build.
  • Patch coverage: 3 uncovered changes across 1 file (83 of 86 lines covered, 96.51%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
js/src/progress.ts 86 83 96.51%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 9865
Covered Lines: 9462
Line Coverage: 95.91%
Relevant Branches: 5510
Covered Branches: 4903
Branch Coverage: 88.98%
Branches in Coverage %: Yes
Coverage Strength: 392.96 hits per line

💛 - Coveralls

The cap circles faded over 1 px, which the body layer's hard edge did not, so
the joint showed a soft shoulder that grew with zoom. A hard stop is
antialiased by the browser like any gradient edge and lines up with the body.
The section's first sentence already says the mask covers .progress-stacked,
and the example set its height on the stack, which the nested .progress does
not follow, so the fill covered only the top half.
Add `--cui-progress-segment-border-radius`, half the segment width by default,
so a segmented track can draw square or lightly rounded segments instead of
pills only. The mask becomes a rounded rectangle per segment: two crossed bars
plus a circle in each corner. At the default radius the shape is the same pill
as before; at `0px` the corner circles collapse and the bars form a square.

The value feeds the mask's `calc()`, so it has to be a length: a unitless `0`
is a number there, which invalidates `mask-size` and drops the whole mask.
Documented on the page and in the migration entry.
`--cui-progress-segment-border-radius` defaults to `--cui-radius-2` instead of
half the segment width, so widening a segment keeps the same corner rather than
scaling it into a bigger cap. On the default width the two are equal, so the
default look is unchanged. The mask clamps the radius to half the segment
width, as `border-radius` does, so a segment narrower than twice the radius
stays a pill instead of inverting the corner circles.
Segments are a count now, `--cui-progress-segments` (40 by default), instead
of a fixed width: the mask tile is `(100% + gap) / n`, so the pitch is known
to the stylesheet, segments scale with the track and the gap stays put. The
mask repeats along the track only — `repeat-x no-repeat` is not a valid pair,
it fell back to `repeat` on both axes and the body layer squared the corners.
`--cui-progress-segment-width` is gone with it.

With the pitch known, the bar's width can be a whole number of segments:
`.progress-segmented > .progress-bar` reads `--cui-progress-segments-filled`
and ends on that segment's far edge. The new Progress plugin sets it: it
initializes on every `.progress-segmented`, reads the bar's `aria-valuenow`,
`aria-valuemin` and `aria-valuemax`, floors the covered segments so the bar
never reads as complete early, and follows the attributes through a
MutationObserver. A bar without `aria-valuenow` is left alone, so a page can
set the variable by hand or keep an inline `width` with a partial segment.

Docs: Segmented rewritten around the count, a Usage section for the plugin,
migration entries. Spec covers floor, min/max scaling, clamping, the
observer, dispose, data API and the jQuery interface.

bundlewatch: the six JS ceilings go up by 1 kB; the plugin left 30 bytes on
`coreui.esm.js`, and on the v6 line size work is deferred to the end.
Below roughly 400 px the 40 default segments get narrower than two corner
radii and the corner circles spilled into the gaps, drawing bone-shaped ends.
Chromium accepts no math function that mixes a length with a percentage in a
radial-gradient size, so the radius cannot be clamped there; instead a first
mask layer covering just the segment's box is composited with `intersect`
over the shape layers, so whatever a corner draws past the segment is cut off
at the gap. Tiling stays at one pitch per segment with `repeat` — `space`
with a segment-sized tile packed extra tiles whenever the gap was wider than
the segment.
Add `--cui-progress-segment-min-width` (.375rem). The plugin watches the
track with a ResizeObserver and, when the token's count would push segments
below that width, writes the count that fits to `--cui-progress-segments-fit`
and recounts the fill against it; the mask and the bar read the fit value
with the token as fallback, so the token stays the ceiling and a page can
still change it. A 150 px track shows 13 of the default 40, a 520 px one all
of them. Zero minimum width disables the cap.

Token lengths are read from computed style, so the plugin converts `rem`
and `em` itself; other units pass through as pixels.

bundlewatch: `coreui.css` ceilings up by .5 kB, 60 bytes were left.
`$progress-segments` is `null` by default and the token is only emitted when
it is set. Without it the plugin packs as many segments of
`--cui-progress-segment-min-width` as fit and follows the track; with it the
count is the ceiling. The stylesheet keeps working without either: the pitch
is a custom property computed from the count, so when no count exists it is
invalid and `var()` falls back to `min-width + gap` tiles, with whatever is
left at the end of the track clipped. Gap and minimum width default to .25rem.
A segment narrower than two radii had its corner circles cross, so the top
and bottom edges showed two humps instead of one cap. The plugin now writes
`--cui-progress-segment-radius-fit`: the token capped at half the segment
width and half the track height, as `border-radius` would do. The mask reads
it with a fallback of the token capped at half the minimum width — the only
segment width the stylesheet knows on its own, and exactly right when no
count is set. Chromium accepts `min()` in a circle radius as long as every
operand is a length, which this fallback keeps to.
@mrholek
mrholek merged commit e042f13 into v6-dev Sep 6, 2026
9 checks passed
@mrholek
mrholek deleted the feat/progress-gradient-segments-v6 branch September 6, 2026 00:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants