segmented-progress-indicator
v22.0.0
Published
A read-only, segmented progress gauge component for Angular.
Readme
segmented-progress-indicator
A read-only, decorative gauge: a row of equal-width segments showing how a current value compares to a target — no interaction, no state of its own, just a reflection of whatever it's given.
segments=5, value=47, total=100
options.snapToSegment=true (default) options.snapToSegment=false
┌───┬───┬───┬───┬───┐ ┌───┬───┬───┬───┬───┐
│▓▓▓│▓▓▓│▓▓▓│ │ │ │▓▓▓│▓▓▓│▓░░│ │ │
└───┴───┴───┴───┴───┘ └───┴───┴───┴───┴───┘
whole segments light segments are dividers only;
up to cover 47% fill edge lands at exactly 47%Breaking change: as of this version, every configuration input except
segments/total/valuehas moved into a singleoptionsobject. See "Migrating to the options input" below if you're upgrading from an earlier version.
Setup
npm install segmented-progress-indicatorPeer dependency: @angular/core (^22.0.0). No @angular/common, forms, or Material — the template uses built-in @for, and there's nothing interactive to wire up.
import { SegmentedProgressIndicatorModule } from 'segmented-progress-indicator'
@NgModule({
imports: [SegmentedProgressIndicatorModule]
})
export class AppModule {}Quick start
<app-segmented-progress-indicator
[total]="quota"
[value]="current"
></app-segmented-progress-indicator>That's the whole minimum: five equal segments (the default segments count), lighting up left-to-right as value approaches total. Everything else in this doc is optional configuration on top of that.
How it works
segmentsdivides the width into that many equal blocks — every segment is always rendered, there is no collapsed state, even atvalue = 0(it's just fully empty).valueandtotalproduce a percentage (value / total, clamped to 0–100%, and0%whenevertotalis0rather than dividing by zero).options.snapToSegment(defaulttrue) chooses between two mutually exclusive fill modes:- Whole-segment — the number of fully-lit segments is the fewest whose combined share still covers the percentage, using ceiling rounding. Any progress into a segment's share lights that whole segment.
- Proportional (
options.snapToSegment=false) — segments become dividers over one continuous fill. Each segment fills exactly its own share, so the filled edge lands at the exact percentage instead of snapping to a boundary. At most one segment is ever partially filled; every segment before it is fully lit, every segment after it is empty.
- Under the hood, the fill is one continuous element (not one per segment), inset 1px from the track's edges so the track color shows through as a thin margin around it. Segment boundaries are a separate overlay, rendered twice — once beneath the fill in the border color, once above it in the track color — so a divider line stays visible whether it falls on the plain track or crosses the fill. Rendering the fill as one element (rather than one per segment) is also what makes
options.animation.animatea single coherent sweep, rather than several independently-animating boxes that could fall out of sync with each other. options.appearance.borderandoptions.appearance.colorcontrol the bar's border and colors;options.appearance.backgroundColoroverrides just the track. All three default to the original hardcoded look, so nothing changes unless you set them. Segment height (14px) and corner radius (square,0) are fixed, with no inputs to override them.options.display.showValue,options.display.alignment,options.display.valueSize,options.display.formatter, andoptions.display.labeloptionally render the current value above the bar and a fixed-style caption below it — independent of whether the segments themselves show any progress.options.display.showPercentageoptionally renders the truevalue/totalpercentage (never the whole-segment-mode snapped fill amount) at the bar's right edge, formatted to one decimal place. It's independent ofshowValue/alignment— the value (if shown) keeps rendering above the bar exactly as it always has, unaffected byshowPercentage.options.animation.animate/options.animation.durationoptionally ease the fill instead of snapping — on mount, and on every later value-driven change.- It never emits anything and never reads anything back — it's driven entirely by its inputs.
Options
segments, total, and value are individual top-level inputs. Everything else is configured through a single options input.
| Input | Type | Default | Description |
|---|---|---|---|
| segments | number | 5 | How many equal blocks the bar is divided into. |
| total | number | 100 | The value representing 100% progress. 0 is treated as 0% rather than dividing by zero. |
| value | number | 0 | The current value, measured against total. Values at or below 0 empty every segment; values at or beyond total fully light every segment — in both fill modes. |
| options | SegmentedProgressIndicatorOptions | SegmentedProgressIndicatorOptions.adapt() | Configures everything else — fill mode, appearance, value/percentage display, and animation. Never undefined. Build a value with SegmentedProgressIndicatorOptions.adapt({ ...partial }) — omitting options entirely, or any group or property within the object you pass to adapt(), falls back to that property's default below. See "Options shape" below, and "Migrating to the options input" for how to construct one. |
Options shape
| Property | Type | Default | Description |
|---|---|---|---|
| options.snapToSegment | boolean | true | true = whole-segment mode (ceiling rounding). false = proportional mode (segments as dividers, exact fill edge). |
| options.appearance.border | boolean | false | Draws a thin (1px) solid border around the whole bar, in the fill color at 30% opacity (always based on the fill color, regardless of backgroundColor). |
| options.appearance.color | string \| undefined | undefined | When set, drives the fill color, the border color (30% opacity), and the track color (20% opacity, unless overridden by backgroundColor). Leave unset to keep the original hardcoded colors. |
| options.appearance.backgroundColor | string \| undefined | undefined | When set, the track renders using this value directly, overriding the color-derived 20%-opacity tint. Does not affect the fill or border colors. |
| options.display.showValue | boolean | false | Renders the current value as text above the bar — the raw value number by default, or as formatted by formatter. Unaffected by showPercentage. |
| options.display.showPercentage | boolean | false | Renders the true value/total percentage (not the whole-segment-mode snapped fill amount), formatted to one decimal place (e.g. "48.5%"), anchored to the bar's right edge and vertically centered against it — the same position for every alignment, independent of showValue. |
| options.display.formatter | (value: number) => string \| undefined | undefined | When set, its return value is what showValue displays instead of the raw number. Never affects the fill/segment calculations, which always use the raw value. Does not apply to showPercentage's text. |
| options.display.alignment | 'left' \| 'center' \| 'right' | 'left' | Text-aligns both the showValue text and label text. Has no effect on showPercentage's position. |
| options.display.valueSize | string | '1rem' | CSS font-size for the showValue text only (always bold). Does not affect label's size. |
| options.display.label | string \| undefined | undefined | When set, renders that text below the bar at a fixed 0.8rem/semi-bold, using the same alignment as the value. |
| options.animation.animate | boolean | false | When true, the fill eases (0% on mount, and between values afterward) instead of snapping — ease-out, respecting prefers-reduced-motion. |
| options.animation.duration | number | 300 | Animation duration in milliseconds. Has no effect while animate is false. |
Outputs
None — the component is read-only and never emits.
Exported types
| Export | Kind | Use |
|---|---|---|
| SegmentedProgressIndicatorModule | NgModule | Import to use the component. |
| SegmentedProgressIndicatorComponent | Component | The app-segmented-progress-indicator class, for typed @ViewChild. |
| SegmentedProgressIndicatorDemoComponent | Component | app-segmented-progress-indicator-demo — every option on one page, useful as a live reference. |
| SegmentedProgressIndicatorOptions | Class | The options input's type. Import it and call .adapt({ ...partial }) to build a value — see "Migrating to the options input". |
Migrating to the options input
Every input except segments/total/value moved into options, which is a SegmentedProgressIndicatorOptions — never a plain object literal, and never undefined. Build a value by importing the class and calling its static adapt() with whatever you want to override; everything else falls back to its documented default:
import { SegmentedProgressIndicatorOptions } from 'segmented-progress-indicator';[options]="SegmentedProgressIndicatorOptions.adapt({ appearance: { border: true } })"For a template to call .adapt() directly like this, expose the class on your component first:
protected readonly Options = SegmentedProgressIndicatorOptions;[options]="Options.adapt({ appearance: { border: true } })"Map each old binding to its new adapt() argument path:
| Old input | New options argument |
|---|---|
| [snapToSegment]="x" | Options.adapt({ snapToSegment: x }) |
| [border]="x" | Options.adapt({ appearance: { border: x } }) |
| color="x" | Options.adapt({ appearance: { color: 'x' } }) |
| backgroundColor="x" | Options.adapt({ appearance: { backgroundColor: 'x' } }) |
| [showValue]="x" | Options.adapt({ display: { showValue: x } }) |
| [showPercentage]="x" | Options.adapt({ display: { showPercentage: x } }) |
| alignment="x" | Options.adapt({ display: { alignment: 'x' } }) |
| label="x" | Options.adapt({ display: { label: 'x' } }) |
| valueSize="x" | Options.adapt({ display: { valueSize: 'x' } }) |
| [formatter]="fn" | Options.adapt({ display: { formatter: fn } }) |
| [animate]="x" | Options.adapt({ animation: { animate: x } }) |
| [duration]="x" | Options.adapt({ animation: { duration: x } }) |
Properties that used to be on separate inputs but belong to the same group combine into one object — e.g. [border]="true" color="#6750a4" [showValue]="true" alignment="left" becomes:
[options]="Options.adapt({
appearance: { border: true, color: '#6750a4' },
display: { showValue: true, alignment: 'left' }
})"For a value used once (not per-render in a template), it's cheaper to build it as a component field instead of calling adapt() inline in the binding:
readonly barOptions = SegmentedProgressIndicatorOptions.adapt({
appearance: { border: true, color: '#6750a4' },
display: { showValue: true, alignment: 'left' },
});[options]="barOptions"Examples
Every example below that calls Options.adapt({...}) inline in a template assumes the consuming component exposes the class first, exactly as shown in "Migrating to the options input":
import { SegmentedProgressIndicatorOptions } from 'segmented-progress-indicator';
protected readonly Options = SegmentedProgressIndicatorOptions;Whole-segment mode (default)
<app-segmented-progress-indicator
[segments]="5"
[total]="quota"
[value]="current"
></app-segmented-progress-indicator>Custom segment count
<app-segmented-progress-indicator
[segments]="10"
[total]="100"
[value]="47"
></app-segmented-progress-indicator>segments can be any count — five is just the default. Five of ten light here (47% ceiling-rounds up to the sixth).
Proportional fill
<app-segmented-progress-indicator
[segments]="10"
[total]="100"
[value]="47"
[options]="Options.adapt({ snapToSegment: false })"
></app-segmented-progress-indicator>Segments become dividers only; the fill edge lands at exactly 47% instead of snapping to a segment boundary.
Next to a formatted value in a table cell
<div class="metric-cell">
<span>{{ row.current | number }} / {{ row.target | number }}</span>
<app-segmented-progress-indicator
[total]="row.target"
[value]="row.current"
></app-segmented-progress-indicator>
</div>The indicator has no label or accessible name of its own — the formatted numbers next to it are what a screen reader (and a sighted user glancing at exact figures) actually relies on. This is the pattern to reach for when you'd rather not use options.display.showValue/formatter at all.
Border and color
<app-segmented-progress-indicator
[total]="100"
[value]="60"
[options]="Options.adapt({ appearance: { border: true, color: '#6750a4', backgroundColor: '#f5eefa' } })"
></app-segmented-progress-indicator>options.appearance.color drives the fill and the border (when border is true, at 30% opacity). The unfilled track is, by default, that same color at 20% opacity (color-mix(in srgb, <color> 20%, transparent), not a lightened mix with white) — or set options.appearance.backgroundColor to use an exact value for the track instead, independent of color. backgroundColor never affects the fill or border; the border always tracks the fill color, independent of backgroundColor. Leave color/backgroundColor unset to keep today's original hardcoded colors.
Custom track color only
<app-segmented-progress-indicator
[total]="100"
[value]="60"
[options]="Options.adapt({ appearance: { backgroundColor: '#f5eefa' } })"
></app-segmented-progress-indicator>options.appearance.backgroundColor works independently of color — the fill and border can stay on their defaults while only the track changes.
Value above the bar and a label below it
<app-segmented-progress-indicator
[total]="100"
[value]="4100000"
[options]="Options.adapt({
display: { showValue: true, alignment: 'center', valueSize: '1.2rem', label: 'Sales Progress' }
})"
></app-segmented-progress-indicator>options.display.showValue renders the raw value number as-is by default — there's no currency/unit formatting built in (this package still has no @angular/common dependency; see "Formatting the displayed value" below for that). alignment text-aligns both the value and the label; only the value's font size is configurable, via valueSize — label is always 0.8rem/semi-bold. Both render independently of whether the segments themselves are showing, so they still appear even at value=0.
Percentage at the end of the bar
<app-segmented-progress-indicator
[total]="100"
[value]="60"
[options]="Options.adapt({ display: { showValue: true, showPercentage: true, alignment: 'left' } })"
></app-segmented-progress-indicator>options.display.showPercentage renders the true value/total percentage (60.0% here) at the bar's right edge, independent of alignment. showValue (if shown) keeps rendering above the bar exactly as it always has — showPercentage doesn't change where or how it renders.
Formatting the displayed value
formatCurrencyCompact(value: number): string {
return `$${(value / 1_000_000).toFixed(1)}M`;
}<app-segmented-progress-indicator
[total]="10000000"
[value]="4500000"
[options]="Options.adapt({ display: { showValue: true, formatter: formatCurrencyCompact } })"
></app-segmented-progress-indicator>options.display.formatter takes the raw value and returns the string to display — here, 4500000 renders as $4.5M. It only changes what's displayed; value itself still drives the fill percentage and segment counts unchanged. There's no built-in formatting logic (no currency/locale/unit support) — you supply the function.
Animating the fill
<app-segmented-progress-indicator
[total]="100"
[value]="currentValue"
[options]="Options.adapt({ animation: { animate: true, duration: 500 } })"
></app-segmented-progress-indicator>With options.animation.animate=true, the fill starts at 0% and eases up to its real position on first render, and every later value (or total/segments/snapToSegment) change eases to its new position instead of snapping — ease-out, over duration milliseconds (default 300). duration has no effect while animate is false (the default, unchanged behavior). No @angular/animations dependency is added — it's a plain CSS transition plus Angular's native animate.enter for the mount case. The OS-level prefers-reduced-motion setting overrides animate automatically — see Gotchas.
Single continuous bar (no visible dividers)
<app-segmented-progress-indicator
[segments]="1"
[total]="100"
[value]="60"
[options]="Options.adapt({ snapToSegment: false, appearance: { border: true, color: '#6750a4', backgroundColor: '#f5eefa' } })"
></app-segmented-progress-indicator>segments=1 removes internal dividers entirely, so the bar reads as one plain continuous fill. Pair it with options.snapToSegment=false — with only one segment, the default whole-segment (ceiling-rounding) mode would round any value above 0% up to a fully-lit bar, which is misleading for what's meant to look like a normal, exact-percentage progress bar.
Everything together
For a value this elaborate, declare it once as a component field rather than calling adapt() inline on every render:
readonly everythingOptions = SegmentedProgressIndicatorOptions.adapt({
snapToSegment: false,
animation: { animate: true },
appearance: { border: true, color: '#6750a4', backgroundColor: '#f5eefa' },
display: {
showValue: true,
alignment: 'left',
valueSize: '1.2rem',
formatter: this.formatCurrencyCompact,
label: 'Sales Progress',
},
});<app-segmented-progress-indicator
[segments]="1"
[total]="100"
[value]="animatedValue"
[options]="everythingOptions"
></app-segmented-progress-indicator>Every option composes independently — there's no combination that conflicts with another. This is the same shape as the demo's "single continuous bar" composite: a themed, labeled, animated, formatted single bar.
Styling
Colors default to the original hardcoded values (#dee2e6 track, #1976d2 fill, no border) but are now configurable via the options.appearance group — see Examples above. There's still no CSS custom property or design-token surface; setting color/backgroundColor is the supported way to theme the bar.
The fill is one continuous element spanning the bar's full width, sized to the computed percentage, inset 1px from the track's edges. Segment boundaries are a separate overlay (not individual boxes), drawn both beneath and above the fill so a divider stays visible over the plain track or over the fill. The whole bar is a fixed 14px tall with square corners (no radius), and the fill's 1px inset is likewise fixed — none of these are configurable.
Gotchas
A single low percentage still lights a whole segment. With the default 5 segments, value=1, total=100 (1%) still lights one full segment in whole-segment mode — ceiling rounding means any progress into a segment's share lights it entirely, not just progress past its midpoint.
total=0 reads as empty, not an error. Both the percentage and the highlighted count default to 0 when total is 0, rather than producing NaN or throwing.
Changing segments or options.snapToSegment is instant by default. With options.animation.animate unset (or false), there is no crossfade or transition on the fill — a value or mode change repaints immediately. Set options.animation.animate=true to ease fill-width changes instead — see "Animating the fill" above.
No accessible name or role. The component intentionally exposes none — pair it with visible text (or an aria-label on a wrapping element) if the percentage needs to be announced. (Or use options.display.showValue/label, which render as plain visible text — see Accessibility below.)
The bar never disappears, even at value=0 (or unset). Every configured segment always renders — it's just fully empty at 0%. If you want a genuinely empty area to have no visible footprint at all, wrap the component in your own conditional (e.g. @if (value > 0)).
options.display.showValue shows the raw number as a string unless you supply a formatter. value is a plain number used in the component's own percentage math, and there's no @angular/common/pipe dependency built in. Pass a formatter function (see "Formatting the displayed value" above) for $4.1 M-style text — it only changes what's displayed, never the number driving the fill.
options.animation.animate respects prefers-reduced-motion automatically. If the user's OS has requested reduced motion, the fill never animates, even with animate="true" — there's no separate opt-out needed.
options.display.showPercentage can visually disagree with a whole-segment-mode bar. The percentage text always reflects the true value/total percentage, never the ceiling-rounded amount the segments themselves light up to. value=47, total=100 in the default whole-segment mode lights 3 of 5 segments (a visual 60%) while the percentage text still reads 47.0%.
Omitting options (or any group/property within it) is always safe. Every group (appearance, display, animation) and every leaf property resolves to its documented default independently — there's no need to spread in defaults yourself or provide every property once you set any one of them.
Accessibility
The indicator has no interactive controls, so nothing requires keyboard focus, and it carries no semantic role or accessible name of its own. options.display.showValue/label, when set, render as plain visible text content — not an ARIA role or accessible name — so they're read by assistive technology the same way any other text on the page is. When both are unset, any textual representation of the value remains the surrounding content's responsibility, not the indicator's.
