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.
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.
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
Prop
Type
Default
value
number
0
How far along it is. Anything outside the range is brought back into it.
label
string
none
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.
max
number
100
What counts as finished. The other end is always zero.
indeterminate
boolean
false
Says that the progress cannot be measured: the ring turns continuously and the value is ignored.
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.
size
number | string
none
The diameter, always in pixels: 96 and '96' both give 96px.
thickness
number | string
none
How thick the ring is, always in pixels: 8 and '8' both give 8px.
shape
ProgressCircularShape'rounded' | 'square'
'rounded'
Whether the ends of the drawn arc are rounded or cut square.
showValue
boolean
false
Writes the percentage in the middle of the ring. It is ignored while the progress is unmeasurable, there being no figure to write.
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
}