UIPackage
Menu

Loading Bar

loading-bar ui
Edit on GitHub

Top-of-viewport NProgress-style progress bar. Drive it imperatively via the useLoadingBar composable (start/finish/error/inc) or with v-model. Supports indeterminate mode, custom color and height, top/bottom anchoring, and an optional trailing spinner.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/loading-bar.json
Named registry: npx shadcn-vue@latest add @uipkge/loading-bar Installs to: app/components/ui/loading-bar/

Examples

Props

Name Type / Values Default Required
modelValue

0–100 progress value. Use with v-model or drive via the composable.

number 0 optional
color

Bar color. Accepts any CSS color value.

string '' optional
height

Bar height in px.

number 3 optional
indeterminate

Indeterminate sliding animation (ignores modelValue).

boolean false optional
position

Anchor the bar to the top or bottom of the viewport.

'top''bottom'
'top' optional
spinner

Show a spinner at the trailing edge of the bar.

boolean false optional
error

Error state tints the bar.

boolean false optional
hidden

Hide the bar entirely (e.g. when finished).

boolean false optional
class HTMLAttributes['class'] optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

LoadingBarHandle
interface LoadingBarHandle {
  start: (from?: number) => void
  finish: () => void
  error: () => void
  inc: (amount?: number) => void
  set: (value: number) => void
}

Files installed (3)

  • app/components/ui/loading-bar/LoadingBar.vue 4.5 kB
    <script setup lang="ts">
    import { computed, onBeforeUnmount, ref, watch } from 'vue'
    import type { HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    
    const props = withDefaults(
      defineProps<{
        /** 0–100 progress value. Use with v-model or drive via the composable. */
        modelValue?: number
        /** Bar color. Accepts any CSS color value. */
        color?: string
        /** Bar height in px. */
        height?: number
        /** Indeterminate sliding animation (ignores modelValue). */
        indeterminate?: boolean
        /** Anchor the bar to the top or bottom of the viewport. */
        position?: 'top' | 'bottom'
        /** Show a spinner at the trailing edge of the bar. */
        spinner?: boolean
        /** Error state tints the bar. */
        error?: boolean
        /** Hide the bar entirely (e.g. when finished). */
        hidden?: boolean
        class?: HTMLAttributes['class']
      }>(),
      {
        modelValue: 0,
        color: '',
        height: 3,
        indeterminate: false,
        position: 'top',
        spinner: false,
        error: false,
        hidden: false,
      },
    )
    
    const emit = defineEmits<{
      (e: 'update:modelValue', value: number): void
      (e: 'finish'): void
    }>()
    
    const internal = ref(props.modelValue)
    let raf: number | null = null
    
    watch(
      () => props.modelValue,
      (v) => {
        internal.value = v
      },
    )
    
    const pct = computed(() => Math.min(100, Math.max(0, internal.value)))
    const barColor = computed(() => props.color || (props.error ? 'var(--destructive)' : 'var(--primary)'))
    const visible = computed(() => !props.hidden && (props.indeterminate || internal.value > 0))
    
    function set(v: number) {
      internal.value = v
      emit('update:modelValue', v)
      if (v >= 100) emit('finish')
    }
    
    // Imperative API exposed for the composable / parent to drive the bar.
    function start(from = 20) {
      set(from)
      tick(from)
    }
    
    function tick(target: number) {
      if (raf) cancelAnimationFrame(raf)
      const step = () => {
        if (internal.value >= 100 || internal.value >= target) return
        const next = Math.min(target, internal.value + (100 - internal.value) * 0.02 + 0.5)
        set(next)
        if (next < target) raf = requestAnimationFrame(step)
      }
      raf = requestAnimationFrame(step)
    }
    
    function inc(amount = 10) {
      set(Math.min(99, internal.value + amount))
    }
    
    function finish() {
      if (raf) cancelAnimationFrame(raf)
      set(100)
    }
    
    function fail() {
      if (raf) cancelAnimationFrame(raf)
      internal.value = 100
      // error prop drives the color; emit finish so callers can hide.
      emit('update:modelValue', 100)
      emit('finish')
    }
    
    onBeforeUnmount(() => {
      if (raf) cancelAnimationFrame(raf)
    })
    
    defineExpose({ start, finish, fail, error: fail, inc, set })
    </script>
    
    <template>
      <div
        data-uipkge
        data-slot="loading-bar"
        :data-position="position"
        :data-state="error ? 'error' : indeterminate ? 'indeterminate' : 'determinate'"
        :class="
          cn(
            'pointer-events-none fixed left-0 z-[9999] w-full transition-opacity duration-300',
            position === 'top' ? 'top-0' : 'bottom-0',
            visible ? 'opacity-100' : 'opacity-0',
            props.class,
          )
        "
        :style="{ height: `${height}px` }"
        role="progressbar"
        :aria-valuemin="0"
        :aria-valuemax="100"
        :aria-valuenow="indeterminate ? undefined : pct"
        :aria-hidden="!visible"
      >
        <!-- Track -->
        <div class="absolute inset-0 bg-transparent" />
    
        <!-- Determinate bar -->
        <div
          v-if="!indeterminate"
          data-slot="loading-bar-fill"
          class="absolute inset-y-0 left-0 transition-[width] duration-200 ease-out"
          :style="{ width: `${pct}%`, backgroundColor: barColor }"
        >
          <div
            v-if="spinner"
            data-slot="loading-bar-spinner"
            class="absolute top-1/2 right-0 size-3 translate-x-1/2 -translate-y-1/2 animate-spin rounded-full border-2 border-current border-t-transparent"
            :style="{ color: barColor }"
          />
        </div>
    
        <!-- Indeterminate sliding bar -->
        <div
          v-else
          data-slot="loading-bar-indeterminate"
          class="loading-bar-indeterminate absolute inset-y-0 w-1/3"
          :style="{ backgroundColor: barColor }"
        >
          <div
            v-if="spinner"
            data-slot="loading-bar-spinner"
            class="absolute top-1/2 right-0 size-3 translate-x-1/2 -translate-y-1/2 animate-spin rounded-full border-2 border-current border-t-transparent"
            :style="{ color: barColor }"
          />
        </div>
      </div>
    </template>
    
    <style scoped>
    @media (prefers-reduced-motion: no-preference) {
      .loading-bar-indeterminate {
        animation: loading-bar-slide 1.2s ease-in-out infinite;
      }
    }
    
    @keyframes loading-bar-slide {
      0% {
        left: -33%;
      }
      100% {
        left: 100%;
      }
    }
    </style>
  • app/components/ui/loading-bar/useLoadingBar.ts 1.7 kB
    import { ref, shallowRef } from 'vue'
    import type { ComponentPublicInstance } from 'vue'
    
    export interface LoadingBarHandle {
      start: (from?: number) => void
      finish: () => void
      error: () => void
      inc: (amount?: number) => void
      set: (value: number) => void
    }
    
    /**
     * Composable that drives a <LoadingBar> instance via a template ref.
     *
     * Usage:
     *   const bar = useLoadingBar()
     *   <LoadingBar :ref="bar.setRef" />
     *   bar.start()
     *   await fetch(...)
     *   bar.finish()
     */
    export function useLoadingBar() {
      const refEl = shallowRef<ComponentPublicInstance | null>(null)
      const loading = ref(false)
      const isError = ref(false)
    
      function setRef(el: Element | ComponentPublicInstance | null) {
        refEl.value = el as ComponentPublicInstance | null
      }
    
      function getHandle(): LoadingBarHandle | null {
        return (refEl.value as unknown as LoadingBarHandle) ?? null
      }
    
      function start(from = 20) {
        isError.value = false
        loading.value = true
        const h = getHandle()
        if (h && typeof h.start === 'function') h.start(from)
      }
    
      function finish() {
        loading.value = false
        const h = getHandle()
        if (h && typeof h.finish === 'function') h.finish()
      }
    
      function error() {
        isError.value = true
        loading.value = false
        const h = getHandle()
        if (h && typeof h.error === 'function') h.error()
      }
    
      function inc(amount = 10) {
        const h = getHandle()
        if (h && typeof h.inc === 'function') h.inc(amount)
      }
    
      function set(value: number) {
        const h = getHandle()
        if (h && typeof h.set === 'function') h.set(value)
      }
    
      return {
        setRef,
        loading,
        isError,
        start,
        finish,
        error,
        inc,
        set,
      }
    }
  • app/components/ui/loading-bar/index.ts 0.1 kB
    export { default as LoadingBar } from './LoadingBar.vue'
    export { useLoadingBar, type LoadingBarHandle } from './useLoadingBar'

Raw manifest: https://uipkge.dev/r/vue/loading-bar.json