diff --git a/src/components/base/progress_badge/progress_badge.md b/src/components/base/progress_badge/progress_badge.md
new file mode 100644
index 0000000000000000000000000000000000000000..1cab06d5ad4c8abdc8a5a4a06b6ac5e132802f0f
--- /dev/null
+++ b/src/components/base/progress_badge/progress_badge.md
@@ -0,0 +1,44 @@
+## Usage
+
+Signify the progress of something in a condensed way using a badge that
+displays it as a fraction of a circle.
+
+```html
+
+```
+
+You can optionally provide a `warning-at-percent` prop. If you set it to 60,
+the badge will appear in a warning state once the count exceeds 60% of the
+total. You can also provide it as a fraction (`0.6`).
+
+```html
+
+```
+
+Similarly, you can provide a `danger-at-percent` prop that will make the badge
+appear in a danger state once the count exceeds that amount of the total.
+
+```html
+
+```
+
+If you don't provide a value for `warning-at-percent` and `danger-at-percent`,
+the badge will appear in its default state even when `count >= total`.
+
+You can provide both warning states and danger states. If both are set to the
+same value, the danger state takes precedence.
+
+Since you can provide both fractions and integers, a value of `1` will be
+treated as 100%.
diff --git a/src/components/base/progress_badge/progress_badge.scss b/src/components/base/progress_badge/progress_badge.scss
new file mode 100644
index 0000000000000000000000000000000000000000..2c68d9481a45f51022e8bb2b6fa85a4bbfd59c5e
--- /dev/null
+++ b/src/components/base/progress_badge/progress_badge.scss
@@ -0,0 +1,76 @@
+.gl-progress-badge {
+ @apply gl-inline-flex gl-items-center gl-p-1 gl-decoration-0;
+
+ border-radius: 9999px;
+
+ .gl-progress-badge-chart {
+ @apply gl-size-5;
+
+ .gl-progress-badge-chart-filled, .gl-progress-badge-chart-remaining {
+ fill: none;
+ stroke-width: 7;
+ }
+ }
+
+ .gl-progress-badge-content {
+ @apply gl-px-1 gl-text-sm;
+ }
+}
+
+@mixin gl-progress-badge-variant(
+ $variant,
+ $background-color,
+ $empty-chart-line-color,
+ $filled-chart-line-color,
+ $filled-chart-line-color-darkmode,
+ $text-color
+) {
+ .gl-progress-badge.gl-progress-badge-#{$variant} {
+ background-color: $background-color;
+
+ .gl-progress-badge-content{
+ color: $text-color;
+ }
+
+ .gl-progress-badge-chart {
+ .gl-progress-badge-chart-filled {
+ stroke: $filled-chart-line-color;
+ }
+
+ .gl-progress-badge-chart-remaining {
+ stroke: $empty-chart-line-color
+ }
+ }
+
+ .gl-dark .gl-progress-badge-chart .gl-progress-badge-chart-filled {
+ stroke: $filled-chart-line-color-darkmode;
+ }
+ }
+}
+
+@include gl-progress-badge-variant(
+ $variant: default,
+ $background-color: var(--gl-badge-muted-background-color-default),
+ $empty-chart-line-color: var(--gl-color-alpha-dark-24),
+ $filled-chart-line-color: var(--gl-color-blue-500),
+ $filled-chart-line-color-darkmode: var(--gl-color-blue-800),
+ $text-color: var(--gl-badge-muted-text-color-default),
+);
+
+@include gl-progress-badge-variant(
+ $variant: warning,
+ $background-color: var(--gl-badge-warning-background-color-default),
+ $empty-chart-line-color: var(--gl-color-alpha-dark-16),
+ $filled-chart-line-color: var(--gl-badge-warning-icon-color-default),
+ $filled-chart-line-color-darkmode: var(--gl-badge-warning-icon-color-default),
+ $text-color: var(--gl-badge-warning-text-color-default),
+);
+
+@include gl-progress-badge-variant(
+ $variant: danger,
+ $background-color: var(--gl-badge-danger-background-color-default),
+ $empty-chart-line-color: var(--gl-color-alpha-dark-16),
+ $filled-chart-line-color: var(--gl-badge-danger-icon-color-default),
+ $filled-chart-line-color-darkmode: var(--gl-badge-danger-icon-color-default),
+ $text-color: var(--gl-badge-danger-text-color-default),
+);
diff --git a/src/components/base/progress_badge/progress_badge.spec.js b/src/components/base/progress_badge/progress_badge.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..7b1230e64e95c67b0d1116a93712c17cc7a3463a
--- /dev/null
+++ b/src/components/base/progress_badge/progress_badge.spec.js
@@ -0,0 +1,104 @@
+import { mount } from '@vue/test-utils';
+import ProgressBadge from './progress_badge.vue';
+
+describe('ProgressBadge', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = mount(ProgressBadge, {
+ propsData: props,
+ });
+ };
+
+ const getSvg = () => wrapper.get('svg');
+ const getChartFilledLine = () => getSvg().get('.gl-progress-badge-chart-filled');
+ const getChartEmptyLine = () => getSvg().get('.gl-progress-badge-chart-remaining');
+ const getContent = () => wrapper.get('.gl-progress-badge-content');
+
+ describe('svg structure', () => {
+ beforeEach(() => {
+ createComponent({
+ count: 10,
+ total: 99,
+ });
+ });
+
+ it('uses the correct path configuration that allows using percentage values in stroke-dasharray', () => {
+ const expectedPath =
+ 'M19.5 3.6 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831';
+ expect(getChartFilledLine().attributes('d')).toEqual(expectedPath);
+ expect(getChartEmptyLine().attributes('d')).toEqual(expectedPath);
+ });
+ });
+
+ describe.each`
+ scenario | count | total | warnAtPercent | dangerAtPercent | expectedVariantClass | expectedContent | expectedFilledStroke | expectedEmptyStroke
+ ${'default'} | ${20} | ${100} | ${undefined} | ${undefined} | ${'gl-progress-badge-default'} | ${'20/100'} | ${'20, 100'} | ${'0, 20, 100'}
+ ${'default almost filled, no warning'} | ${599} | ${600} | ${undefined} | ${undefined} | ${'gl-progress-badge-default'} | ${'599/600'} | ${'100, 100'} | ${'0, 100, 100'}
+ ${'warning'} | ${7} | ${12} | ${50} | ${undefined} | ${'gl-progress-badge-warning'} | ${'7/12'} | ${'59, 100'} | ${'0, 59, 100'}
+ ${'warning level as fraction'} | ${126} | ${222} | ${0.5} | ${undefined} | ${'gl-progress-badge-warning'} | ${'126/222'} | ${'57, 100'} | ${'0, 57, 100'}
+ ${'danger'} | ${901} | ${1001} | ${undefined} | ${90} | ${'gl-progress-badge-danger'} | ${'901/1001'} | ${'91, 100'} | ${'0, 91, 100'}
+ ${'danger level as fraction'} | ${19} | ${20} | ${undefined} | ${0.94} | ${'gl-progress-badge-danger'} | ${'19/20'} | ${'95, 100'} | ${'0, 95, 100'}
+ ${'negative count'} | ${-20} | ${100} | ${undefined} | ${undefined} | ${'gl-progress-badge-default'} | ${'-20/100'} | ${'0, 100'} | ${'0, 0, 100'}
+ ${'count > total'} | ${84} | ${67} | ${undefined} | ${undefined} | ${'gl-progress-badge-default'} | ${'84/67'} | ${'100, 100'} | ${'0, 100, 100'}
+ ${'total is 0'} | ${9262} | ${0} | ${undefined} | ${undefined} | ${'gl-progress-badge-default'} | ${'9262/0'} | ${'100, 100'} | ${'0, 100, 100'}
+ ${'warning level is 0'} | ${0} | ${10} | ${0} | ${undefined} | ${'gl-progress-badge-warning'} | ${'0/10'} | ${'0, 100'} | ${'0, 0, 100'}
+ ${'danger level is 0'} | ${0} | ${10} | ${undefined} | ${0} | ${'gl-progress-badge-danger'} | ${'0/10'} | ${'0, 100'} | ${'0, 0, 100'}
+ ${'warning level == danger level'} | ${60} | ${100} | ${60} | ${60} | ${'gl-progress-badge-danger'} | ${'60/100'} | ${'60, 100'} | ${'0, 60, 100'}
+ ${'warning level = 1 will be treated as 100%'} | ${10} | ${100} | ${1} | ${undefined} | ${'gl-progress-badge-default'} | ${'10/100'} | ${'10, 100'} | ${'0, 10, 100'}
+ ${'danger level = 1 will be treated as 100%'} | ${10} | ${100} | ${5} | ${1} | ${'gl-progress-badge-warning'} | ${'10/100'} | ${'10, 100'} | ${'0, 10, 100'}
+ `(
+ '$scenario',
+ ({
+ count,
+ total,
+ warnAtPercent,
+ dangerAtPercent,
+ expectedVariantClass,
+ expectedContent,
+ expectedFilledStroke,
+ expectedEmptyStroke,
+ }) => {
+ beforeEach(() => {
+ createComponent({ count, total, warnAtPercent, dangerAtPercent });
+ });
+
+ it('renders the correct wrapper classes', () => {
+ expect(wrapper.classes()).toContain('gl-progress-badge');
+ expect(wrapper.classes()).toContain(expectedVariantClass);
+ });
+
+ it('renders the percentage as a filled dasharray', () => {
+ expect(getChartFilledLine().attributes('stroke-dasharray')).toEqual(expectedFilledStroke);
+ });
+
+ it('renders the remaining percentage as dasharray', () => {
+ expect(getChartEmptyLine().attributes('stroke-dasharray')).toEqual(expectedEmptyStroke);
+ });
+
+ it('renders the expected content', () => {
+ expect(getContent().text()).toBe(expectedContent);
+ });
+ }
+ );
+
+ describe('validators', () => {
+ it.each([-Infinity, -1, 100.000001, 101, Infinity, 'invalid'])(
+ 'logs a warning when warnAtPercent is %d',
+ (value) => {
+ createComponent({ count: 1, total: 10, warnAtPercent: value });
+
+ expect(wrapper).toHaveLoggedVueWarnings();
+ }
+ );
+
+ it.each([-Infinity, -1, 100.000001, 101, Infinity, 'invalid'])(
+ 'logs a warning when dangerAtPercent is %d',
+ (value) => {
+ createComponent({ count: 1, total: 10, dangerAtPercent: value });
+
+ expect(wrapper).toHaveLoggedVueWarnings();
+ }
+ );
+ });
+});
diff --git a/src/components/base/progress_badge/progress_badge.stories.js b/src/components/base/progress_badge/progress_badge.stories.js
new file mode 100644
index 0000000000000000000000000000000000000000..340d249b5a5d2e5a699233997cc2de6b50002fbe
--- /dev/null
+++ b/src/components/base/progress_badge/progress_badge.stories.js
@@ -0,0 +1,55 @@
+import GlProgressBadge from './progress_badge.vue';
+import readme from './progress_badge.md';
+
+const generateProps = ({
+ count = 33,
+ total = 100,
+ warnAtPercent = 60,
+ dangerAtPercent = 90,
+} = {}) => ({
+ count,
+ total,
+ warnAtPercent,
+ dangerAtPercent,
+});
+
+const Template = (args) => ({
+ components: { GlProgressBadge },
+ props: Object.keys(args),
+ template: ``,
+});
+
+export const Default = Template.bind({});
+Default.args = generateProps();
+
+export const Warning = () => ({
+ components: { GlProgressBadge },
+ data: () => ({
+ total: 100,
+ count: 65,
+ }),
+ template: ``,
+});
+Warning.parameters = { controls: { disable: true } };
+
+export const Danger = () => ({
+ components: { GlProgressBadge },
+ data: () => ({
+ total: 100,
+ count: 90,
+ }),
+ template: ``,
+});
+Danger.parameters = { controls: { disable: true } };
+
+export default {
+ title: 'base/progress-badge',
+ component: GlProgressBadge,
+ parameters: {
+ docs: {
+ description: {
+ component: readme,
+ },
+ },
+ },
+};
diff --git a/src/components/base/progress_badge/progress_badge.vue b/src/components/base/progress_badge/progress_badge.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c9fdfee8521c12bb6c882305bb2fff741360d6c4
--- /dev/null
+++ b/src/components/base/progress_badge/progress_badge.vue
@@ -0,0 +1,105 @@
+
+
+
+
+
+ {{ count }}/{{ total }}
+
+
diff --git a/src/scss/components.scss b/src/scss/components.scss
index 5e9bc5c29a7be761a2d6c6ebca3ff03a780ae62d..e5ab1db22a9249a564b1baf9f7242574b366c6c3 100644
--- a/src/scss/components.scss
+++ b/src/scss/components.scss
@@ -55,6 +55,7 @@
@import '../components/base/pagination/pagination';
@import '../components/base/path/path';
@import '../components/base/popover/popover';
+@import '../components/base/progress_badge/progress_badge';
@import '../components/base/progress_bar/progress_bar';
@import '../components/base/search_box_by_type/search_box_by_type';
@import '../components/base/search_box_by_click/search_box_by_click';
diff --git a/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-base-progress-badge-danger-1-snap.png b/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-base-progress-badge-danger-1-snap.png
new file mode 100644
index 0000000000000000000000000000000000000000..25987382041691ae65cc5e88cc0ed69d3fac4e6c
Binary files /dev/null and b/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-base-progress-badge-danger-1-snap.png differ
diff --git a/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-base-progress-badge-default-1-snap.png b/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-base-progress-badge-default-1-snap.png
new file mode 100644
index 0000000000000000000000000000000000000000..6f1b8be35f5f0327c1070661ea165dee670001c3
Binary files /dev/null and b/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-base-progress-badge-default-1-snap.png differ
diff --git a/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-base-progress-badge-warning-1-snap.png b/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-base-progress-badge-warning-1-snap.png
new file mode 100644
index 0000000000000000000000000000000000000000..047d78aa88f9aa6f74e5e9d0c18f4624f43f5e06
Binary files /dev/null and b/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-base-progress-badge-warning-1-snap.png differ