Keyboard shortcut: Ctrl + K
Get started

Progress linear

A bar that fills as something advances, or animates continuously when there is no figure to report. It can be turned upright, and it can carry its own percentage inside it.

Usage

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

<template>
  <VProgressLinear :value="40" label="Upload" />
</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%

value = 40, max = 100

7 of 12 files, max = 12

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

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

<template>
  <div class="column">
    <VProgressLinear :value="value" thickness="10" label="Upload" show-value />

    <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>

    <!-- `max` says what counts as finished. The other end is always zero. -->
    <div class="labelled">
      <VTypography variant="caption" tone="muted">7 of 12 files, max = 12</VTypography>
      <VProgressLinear :value="7" :max="12" thickness="10" label="Files" show-value />
    </div>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-5);
  max-inline-size: 28rem;
}
.row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--vectis-space-3);
}
.labelled {
  display: grid;
  gap: var(--vectis-space-2);
}
</style>

Indeterminate

indeterminate is for a wait that cannot be measured: the bar animates continuously and the value is ignored. Under reduced motion it is slowed rather than stopped.

vue

For a server that reports no percentage. Under reduced motion the bar is slowed rather than stopped: a motionless loader no longer says that anything is happening, which is the one thing it exists to say.

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

<template>
  <div class="column">
    <!-- One bar crosses the track, flush with each edge at the extremes, so the loop is
         invisible and the track is never empty. The value is ignored: there is nothing
         to report. -->
    <VProgressLinear indeterminate label="Waiting for the server" />

    <VProgressLinear indeterminate tone="neutral" thickness="8" label="Loading" />

    <VTypography variant="body-sm" tone="muted">
      For a server that reports no percentage. Under reduced motion the bar is slowed rather than
      stopped: a motionless loader no longer says that anything is happening, which is the one thing
      it exists to say.
    </VTypography>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-5);
  max-inline-size: 28rem;
}
</style>

Tones

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

vue

accent

success

warning

danger

neutral

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

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

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

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-4);
  max-inline-size: 28rem;
}
.row {
  display: grid;
  grid-template-columns: 5rem 1fr;
  align-items: center;
  gap: var(--vectis-space-4);
}
.name {
  text-align: end;
}
</style>

Custom colours

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

vue

A hex value

A CSS colour name

An oklch() colour

<script setup lang="ts">
import { VProgressLinear, 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="column">
    <div v-for="colour in COLORS" :key="colour.value" class="row">
      <VTypography variant="caption" tone="muted" class="name">{{ colour.caption }}</VTypography>
      <!-- The track's own shade is derived from the colour against the theme, so both
           follow: one value to set, and it stays right in dark mode. -->
      <VProgressLinear :value="60" :color="colour.value" :label="`Upload, ${colour.caption}`" />
    </div>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-4);
  max-inline-size: 28rem;
}
.row {
  display: grid;
  grid-template-columns: 9rem 1fr;
  align-items: center;
  gap: var(--vectis-space-4);
}
.name {
  text-align: end;
}
</style>

Thickness

thickness is always in pixels, whether given as a number or a numeric string, and 4px unless you say otherwise. There is no length prop to go with it: the bar takes the width of whatever holds it.

vue

2px

4px

8px

16px

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

const THICKNESSES = [2, 4, 8, 16]
</script>

<template>
  <div class="column">
    <!-- Always pixels, whether a number or a numeric string. The bar takes the width of
         whatever holds it, so its length is the container's business and not a prop. -->
    <div v-for="thickness in THICKNESSES" :key="thickness" class="row">
      <VTypography variant="caption" tone="muted" class="name">{{ thickness }}px</VTypography>
      <VProgressLinear :value="60" :thickness="thickness" :label="`Upload, ${thickness}px`" />
    </div>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-4);
  max-inline-size: 28rem;
}
.row {
  display: grid;
  grid-template-columns: 4rem 1fr;
  align-items: center;
  gap: var(--vectis-space-4);
}
.name {
  text-align: end;
}
</style>

Shape

shape says whether the ends of the bar are rounded or cut square. It shows on a thick bar and is all but invisible on the default 4px one.

vue

rounded, the default

square

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

<template>
  <div class="column">
    <div class="labelled">
      <VTypography variant="caption" tone="muted">rounded, the default</VTypography>
      <VProgressLinear :value="60" thickness="14" label="Upload, rounded" />
    </div>

    <div class="labelled">
      <VTypography variant="caption" tone="muted">square</VTypography>
      <VProgressLinear :value="60" thickness="14" shape="square" label="Upload, square" />
    </div>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-5);
  max-inline-size: 28rem;
}
.labelled {
  display: grid;
  gap: var(--vectis-space-2);
}
</style>

Content inside the bar

showValue writes the percentage inside the bar and valuePosition says where along it that text sits. The default slot replaces the figure and receives the value, the max and the percentage. The content is rendered twice, once over the track and once over the fill, so it has to be free of side effects.

vue

valuePosition start

55%

valuePosition center

55%

valuePosition end

55%

A slot of your own

7 of 12 files

The bar is 4px by default, so writing inside it means giving it a thickness that can hold a line of text.

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

const POSITIONS: ProgressLinearValuePosition[] = ['start', 'center', 'end']
</script>

<template>
  <div class="column">
    <!-- The text is rendered TWICE: once over the empty track, once over the fill in a
         contrasting colour, the second copy clipped at the fill's edge. Whatever the
         slot renders must therefore be free of side effects. -->
    <div v-for="position in POSITIONS" :key="position" class="labelled">
      <VTypography variant="caption" tone="muted">valuePosition {{ position }}</VTypography>
      <VProgressLinear
        :value="55"
        thickness="22"
        show-value
        :value-position="position"
        :label="`Upload, ${position}`"
      />
    </div>

    <!-- The slot replaces the percentage and receives the value, the max and the
         percentage worked out from them. -->
    <div class="labelled">
      <VTypography variant="caption" tone="muted">A slot of your own</VTypography>
      <VProgressLinear :value="7" :max="12" thickness="22" label="Files uploaded">
        <template #default="{ value, max }">{{ value }} of {{ max }} files</template>
      </VProgressLinear>
    </div>

    <VTypography variant="body-sm" tone="muted">
      The bar is 4px by default, so writing inside it means giving it a thickness that can hold a
      line of text.
    </VTypography>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-5);
  max-inline-size: 28rem;
}
.labelled {
  display: grid;
  gap: var(--vectis-space-2);
}
</style>

Orientation

orientation set to vertical fills the bar from the bottom up, in a right-to-left page as well. Its length is a token, 10rem by default, which a height of your own replaces. The text copies stay horizontal.

vue

25%

60%

90%

indeterminate

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

<template>
  <div class="row">
    <!-- Upright, the bar fills from the BOTTOM up: zero is anchored to the bottom edge,
         so a vertical bar reads the way a gauge does. It takes the height of whatever
         holds it, the way the horizontal one takes the width. -->
    <div v-for="value in [25, 60, 90]" :key="value" class="gauge">
      <VProgressLinear
        :value="value"
        orientation="vertical"
        thickness="14"
        :label="`Tank, ${value} percent`"
      />
      <VTypography variant="caption" tone="muted">{{ value }}%</VTypography>
    </div>

    <div class="gauge">
      <VProgressLinear indeterminate orientation="vertical" thickness="14" label="Filling" />
      <VTypography variant="caption" tone="muted">indeterminate</VTypography>
    </div>
  </div>
</template>

<style scoped>
.row {
  display: flex;
  align-items: end;
  gap: var(--vectis-space-6);
}
.gauge {
  display: grid;
  justify-items: center;
  gap: var(--vectis-space-2);
  /* The bar takes the height it is given, exactly as the horizontal one takes a width. */
  block-size: 9rem;
  grid-template-rows: 1fr auto;
}
</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 bar animates continuously and the value is ignored. It is what to use while waiting for a server that reports no percentage.
toneProgressLinearTone'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 track's own shade is derived from it against the theme, so it follows both.
thicknessnumber | stringnone
How thick the bar is, always in pixels: 12 and '12' both give 12px. It is 4px by default, so showing text inside the bar needs an explicit thickness.
shapeProgressLinearShape'rounded' | 'square''rounded'
Whether the ends of the bar are rounded or square.
showValuebooleanfalse
Writes the percentage inside the bar. It is ignored while the progress is unmeasurable, there being no figure to write.
valuePositionProgressLinearValuePosition'start' | 'center' | 'end''center'
Where that text sits along the bar. On a vertical bar the start is the zero end, hence the bottom.
orientationProgressLinearOrientation'horizontal' | 'vertical''horizontal'
Turns the bar upright, filling from the bottom up.

Slots

SlotType
defaultProgressLinearSlotProps
What to write inside the bar instead of the percentage. It is rendered twice, once over the empty track and once over the filled part in a contrasting colour, each copy cut at the fill's edge, so whatever it renders must be free of side effects.

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 ProgressLinearSlotProps {
  value: number
  max: number
  percent: number
}

CSS variables

TokenValue
--vectis-control-size-progress-linear-thickness0.25rem
--vectis-control-size-progress-linear-length10rem