Keyboard shortcut: Ctrl + K
Get started

Progress circular

A ring that fills as something advances, or turns continuously when there is no figure to report. Its geometry is pure CSS, so changing its size recomputes nothing in JavaScript.

Usage

vue
65%
<script setup lang="ts">
import { VProgressCircular } from 'vectis-ui'
</script>

<template>
  <VProgressCircular :value="65" label="Upload" show-value />
</template>

Examples

Value

value is how far along it is, against a max that says what counts as finished. Anything outside the range is brought back into it.

vue
40%58%

value = 40, max = 100

<script setup lang="ts">
import { ref } from 'vue'
import { VButton, VProgressCircular, VTypography } from 'vectis-ui'

const value = ref(40)
const step = (delta: number) => (value.value += delta)
</script>

<template>
  <div class="column">
    <div class="row">
      <VProgressCircular :value="value" :size="96" show-value label="Upload" />

      <!-- `max` says what counts as finished. The other end is always zero. -->
      <VProgressCircular :value="7" :max="12" :size="96" show-value label="Files" />
    </div>

    <div class="row">
      <VButton variant="outline" tone="neutral" size="sm" @click="step(-25)">-25</VButton>
      <VButton variant="outline" tone="neutral" size="sm" @click="step(25)">+25</VButton>
      <!-- Anything outside the range is brought back into it, so a value of 130 or -10
           needs no clamping of your own. -->
      <VTypography variant="body-sm" tone="muted">value = {{ value }}, max = 100</VTypography>
    </div>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-5);
}
.row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--vectis-space-6);
}
</style>

Indeterminate

indeterminate is for a wait that cannot be measured: the ring turns and the value is ignored. Where a spinner stands in for an icon rather than reporting on a task, VSpinner is the smaller thing to reach for.

vue

For a server that reports no percentage. Where a spinner would stand in for an icon, VSpinner is the smaller thing to reach for.

<script setup lang="ts">
import { VProgressCircular, VTypography } from 'vectis-ui'
</script>

<template>
  <div class="column">
    <div class="row">
      <!-- The ring turns and its arc grows and shrinks on a period of its own, the two
           offset so the movement never settles into a rhythm. The value is ignored:
           there is nothing to report. -->
      <VProgressCircular indeterminate label="Waiting for the server" />
      <VProgressCircular indeterminate :size="64" tone="neutral" label="Loading" />
      <VProgressCircular indeterminate :size="64" :thickness="10" label="Working" />
    </div>

    <VTypography variant="body-sm" tone="muted">
      For a server that reports no percentage. Where a spinner would stand in for an icon, VSpinner
      is the smaller thing to reach for.
    </VTypography>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-5);
}
.row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--vectis-space-6);
}
</style>

Tones

tone says what the progress means, as a colour. There are five rather than a button's three, a ring reporting a state rather than starting an action.

vue

accent

success

warning

danger

neutral

<script setup lang="ts">
import { VProgressCircular, VTypography, type ProgressCircularTone } from 'vectis-ui'

const TONES: ProgressCircularTone[] = ['accent', 'success', 'warning', 'danger', 'neutral']
</script>

<template>
  <div class="row">
    <div v-for="tone in TONES" :key="tone" class="cell">
      <VProgressCircular :value="65" :tone="tone" :label="`Upload, ${tone}`" />
      <VTypography variant="caption" tone="muted">{{ tone }}</VTypography>
    </div>
  </div>
</template>

<style scoped>
.row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--vectis-space-6);
}
.cell {
  display: grid;
  justify-items: center;
  gap: var(--vectis-space-2);
}
</style>

Custom colours

color replaces the tone, as a hex value, a CSS name or an oklch(). The unfilled ring's shade is derived from it against the theme.

vue

A hex value

A CSS colour name

An oklch() colour

<script setup lang="ts">
import { VProgressCircular, VTypography } from 'vectis-ui'

const COLORS = [
  { value: '#7c3aed', caption: 'A hex value' },
  { value: 'teal', caption: 'A CSS colour name' },
  { value: 'oklch(0.72 0.19 45)', caption: 'An oklch() colour' },
]
</script>

<template>
  <div class="row">
    <!-- The unfilled ring's shade is derived from the colour against the theme, so one
         value is enough and it stays right in dark mode. -->
    <div v-for="colour in COLORS" :key="colour.value" class="cell">
      <VProgressCircular :value="65" :color="colour.value" :label="`Upload, ${colour.caption}`" />
      <VTypography variant="caption" tone="muted">{{ colour.caption }}</VTypography>
    </div>
  </div>
</template>

<style scoped>
.row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--vectis-space-6);
}
.cell {
  display: grid;
  justify-items: center;
  gap: var(--vectis-space-2);
}
</style>

Size and thickness

size and thickness are the diameter and the ring, both always in pixels whether given as a number or a numeric string, and independent of one another.

vue

24px

40px

64px

96px

2px ring

4px ring

8px ring

14px ring

<script setup lang="ts">
import { VProgressCircular, VTypography } from 'vectis-ui'

const SIZES = [24, 40, 64, 96]
const THICKNESSES = [2, 4, 8, 14]
</script>

<template>
  <div class="column">
    <!-- The geometry is entirely CSS: the radius is derived from the diameter and the
         thickness, so changing either recomputes nothing in JavaScript. -->
    <div class="row">
      <div v-for="size in SIZES" :key="size" class="cell">
        <VProgressCircular :value="65" :size="size" :label="`Upload, ${size}px`" />
        <VTypography variant="caption" tone="muted">{{ size }}px</VTypography>
      </div>
    </div>

    <!-- Thickness is independent of the diameter, and both are always pixels whether
         given as a number or as a numeric string. -->
    <div class="row">
      <div v-for="thickness in THICKNESSES" :key="thickness" class="cell">
        <VProgressCircular
          :value="65"
          :size="64"
          :thickness="thickness"
          :label="`Upload, ${thickness}px ring`"
        />
        <VTypography variant="caption" tone="muted">{{ thickness }}px ring</VTypography>
      </div>
    </div>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-6);
}
.row {
  display: flex;
  flex-wrap: wrap;
  align-items: end;
  gap: var(--vectis-space-6);
}
.cell {
  display: grid;
  justify-items: center;
  gap: var(--vectis-space-2);
}
</style>

Shape

shape says whether the ends of the drawn arc are rounded or cut square. It shows on a thick ring and is all but invisible on a thin one.

vue

rounded, the default

square

<script setup lang="ts">
import { VProgressCircular, VTypography } from 'vectis-ui'
</script>

<template>
  <div class="row">
    <!-- The shape is the end of the drawn arc, rounded or cut square. It shows on a
         thick ring and is all but invisible on a thin one. -->
    <div class="cell">
      <VProgressCircular :value="65" :size="96" :thickness="14" label="Upload, rounded" />
      <VTypography variant="caption" tone="muted">rounded, the default</VTypography>
    </div>

    <div class="cell">
      <VProgressCircular
        :value="65"
        :size="96"
        :thickness="14"
        shape="square"
        label="Upload, square"
      />
      <VTypography variant="caption" tone="muted">square</VTypography>
    </div>
  </div>
</template>

<style scoped>
.row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--vectis-space-6);
}
.cell {
  display: grid;
  justify-items: center;
  gap: var(--vectis-space-2);
}
</style>

Content in the middle

showValue writes the percentage in the hole of the ring, sized in proportion to the diameter. The default slot replaces it with content of your own and receives the value, the max and the percentage worked out from them.

vue
65%

showValue

7/12

A count

An icon

<script setup lang="ts">
import { VIcon, VProgressCircular, VTypography } from 'vectis-ui'
import { check } from 'vectis-ui/icons'
</script>

<template>
  <div class="row">
    <div class="cell">
      <VProgressCircular :value="65" :size="96" show-value label="Upload" />
      <VTypography variant="caption" tone="muted">showValue</VTypography>
    </div>

    <!-- The slot replaces the percentage and receives the value, the max and the
         percentage worked out from them. It sits in the hole of the ring, on the page
         background, so it takes the page's own text colour. -->
    <div class="cell">
      <VProgressCircular :value="7" :max="12" :size="96" label="Files uploaded">
        <template #default="{ value, max }">
          <span class="count">{{ value }}/{{ max }}</span>
        </template>
      </VProgressCircular>
      <VTypography variant="caption" tone="muted">A count</VTypography>
    </div>

    <div class="cell">
      <VProgressCircular :value="100" :size="96" tone="success" label="Upload finished">
        <VIcon :name="check" :size="32" />
      </VProgressCircular>
      <VTypography variant="caption" tone="muted">An icon</VTypography>
    </div>
  </div>
</template>

<style scoped>
.row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--vectis-space-6);
}
.cell {
  display: grid;
  justify-items: center;
  gap: var(--vectis-space-2);
}
.count {
  font-variant-numeric: tabular-nums;
}
</style>

API

Props

PropTypeDefault
valuenumber0
How far along it is. Anything outside the range is brought back into it.
labelstringnone
What is progressing, in words, for screen readers. It draws nothing on screen and falls back to the design system dictionary; an aria-label or aria-labelledby of your own takes precedence over it.
maxnumber100
What counts as finished. The other end is always zero.
indeterminatebooleanfalse
Says that the progress cannot be measured: the ring turns continuously and the value is ignored.
toneProgressCircularTone'neutral' | 'accent' | 'danger' | 'success' | 'warning''accent'
What the progress means, expressed as a colour.
colorstringnone
A colour of your own, as hex, a CSS name or oklch(), which replaces the tone. The unfilled ring's shade is derived from it against the theme, so it follows both.
sizenumber | stringnone
The diameter, always in pixels: 96 and '96' both give 96px.
thicknessnumber | stringnone
How thick the ring is, always in pixels: 8 and '8' both give 8px.
shapeProgressCircularShape'rounded' | 'square''rounded'
Whether the ends of the drawn arc are rounded or cut square.
showValuebooleanfalse
Writes the percentage in the middle of the ring. It is ignored while the progress is unmeasurable, there being no figure to write.

Slots

SlotType
defaultProgressCircularSlotProps
What to put in the middle of the ring instead of the percentage: a count of files, an icon, a shortened figure.

Types

The types the tables above name, written as the library declares them. The ones carrying export can be imported from vectis-ui to type your own code; the others are the shape of what a slot hands out.

export interface ProgressCircularSlotProps {
  value: number
  max: number
  percent: number
}

CSS variables

TokenValue
--vectis-control-size-progress-circular-diameter3rem
--vectis-control-size-progress-circular-thickness0.25rem