Skip to content

Motion Badge ​

Badge with odometer-style rolling counts and an optional pulsing live dot.

Default Secondary Outline Operational Degraded Failed
Unread notifications:7
Watching1,284

Installation ​

pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/motion-badge.json
Manual installation

Install the dependencies:

bash
npm install motion-v class-variance-authority

Then copy these files into components/ui/motion-badge/ (plus lib/utils.ts if you don't have it):

vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { MotionBadgeVariants } from '.'
import { animate, AnimatePresence, motion, useDomRef, useReducedMotion } from 'motion-v'
import { computed, onMounted, ref, watch } from 'vue'
import { cn } from '@/lib/utils'
import { motionBadgeVariants } from '.'

const props = withDefaults(defineProps<{
  variant?: MotionBadgeVariants['variant']
  size?: MotionBadgeVariants['size']
  /** Show a number that rolls like an odometer whenever it changes. */
  count?: number
  /** Counts above this render as `max+`. */
  max?: number
  /** Locale used to format the count (thousands separators). */
  locale?: string
  /** Show a pulsing "live" dot before the content. */
  live?: boolean
  class?: HTMLAttributes['class']
}>(), {
  max: Infinity,
})

const prefersReduced = useReducedMotion()
// Trust the media query only after mount, so server and hydrated markup always match.
const mounted = ref(false)
onMounted(() => (mounted.value = true))
const reduced = computed(() => mounted.value && prefersReduced.value)
const root = useDomRef()

const text = computed(() => {
  if (props.count === undefined)
    return ''
  const n = Math.max(0, Math.round(props.count))
  const fmt = new Intl.NumberFormat(props.locale)
  return n > props.max ? `${fmt.format(props.max)}+` : fmt.format(n)
})

// Characters keyed from the right, so the ones digit stays the ones digit when the length changes.
const chars = computed(() => [...text.value].map((ch, i, all) => ({ ch, key: all.length - i })))

// 1 = rolling up (count grew), -1 = rolling down.
const direction = ref(1)

watch(() => props.count, (next, prev) => {
  if (next === undefined || prev === undefined || next === prev)
    return
  direction.value = next > prev ? 1 : -1
  if (!reduced.value && root.value)
    animate(root.value, { scale: [1, 1.08, 1] }, { duration: 0.35, ease: 'easeOut' })
})

const roll = {
  enter: (d: number) => ({ y: `${d * 100}%`, opacity: 0, filter: 'blur(2px)' }),
  center: { y: '0%', opacity: 1, filter: 'blur(0px)' },
  exit: (d: number) => ({ y: `${d * -100}%`, opacity: 0, filter: 'blur(2px)' }),
}
const spring = { type: 'spring', stiffness: 380, damping: 30, mass: 0.8 } as const
</script>

<template>
  <motion.span
    ref="root"
    :class="cn(motionBadgeVariants({ variant: props.variant, size: props.size }), props.class)"
  >
    <span v-if="props.live" class="relative inline-flex size-1.5 shrink-0" aria-hidden="true">
      <motion.span
        v-if="!reduced"
        class="absolute inset-0 rounded-full bg-[var(--vx-badge-dot,currentColor)]"
        :initial="{ scale: 1, opacity: 0.7 }"
        :animate="{ scale: 2.8, opacity: 0 }"
        :transition="{ duration: 1.5, repeat: Infinity, ease: 'easeOut' }"
      />
      <span class="relative size-1.5 rounded-full bg-[var(--vx-badge-dot,currentColor)]" />
    </span>

    <slot />

    <template v-if="props.count !== undefined">
      <span class="sr-only">{{ text }}</span>
      <span class="inline-flex" aria-hidden="true">
        <AnimatePresence :initial="false">
          <motion.span
            v-for="c in chars"
            :key="c.key"
            class="relative inline-flex h-[1.2em] items-center justify-center overflow-hidden"
            :initial="reduced ? false : { width: 0, opacity: 0 }"
            :animate="{ width: 'auto', opacity: 1 }"
            :exit="{ width: 0, opacity: 0 }"
            :transition="spring"
          >
            <AnimatePresence :initial="mounted && !reduced" mode="popLayout" :custom="direction">
              <motion.span
                :key="c.ch"
                class="inline-block"
                :custom="direction"
                :variants="reduced ? undefined : roll"
                initial="enter"
                animate="center"
                exit="exit"
                :transition="{ ...spring, filter: { duration: 0.2 } }"
              >
                {{ c.ch }}
              </motion.span>
            </AnimatePresence>
          </motion.span>
        </AnimatePresence>
      </span>
    </template>
  </motion.span>
</template>
ts
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'

export { default as MotionBadge } from './MotionBadge.vue'

export const motionBadgeVariants = cva(
  'inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border font-medium leading-none tabular-nums',
  {
    variants: {
      variant: {
        default: 'border-transparent bg-primary text-primary-foreground',
        secondary: 'border-transparent bg-secondary text-secondary-foreground',
        outline: 'bg-background text-foreground [--vx-badge-dot:var(--color-chart-1)]',
        success: 'border-chart-1/30 bg-chart-1/12 text-foreground [--vx-badge-dot:var(--color-chart-1)]',
        warning: 'border-chart-4/40 bg-chart-4/15 text-foreground [--vx-badge-dot:var(--color-chart-4)]',
        destructive: 'border-transparent bg-destructive text-white',
        brand: 'border-transparent bg-brand text-black',
      },
      size: {
        sm: 'h-5 px-2 text-[0.6875rem]',
        md: 'h-6 px-2.5 text-xs',
        lg: 'h-7 px-3 text-sm',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'md',
    },
  },
)

export type MotionBadgeVariants = VariantProps<typeof motionBadgeVariants>

Usage ​

ts
import { MotionBadge } from '@/components/ui/motion-badge'
vue
<MotionBadge variant="success" live>Operational</MotionBadge>

<!-- count mode: digits roll up when it grows, down when it shrinks -->
<MotionBadge :count="unread" :max="99" variant="destructive" size="sm" />

Only the digits that actually change roll; new digit columns slide open when the number gets longer. The badge gives a small bump on every change. Screen readers get the plain number.

API ​

PropTypeDefaultDescription
variant'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'destructive' | 'brand''default'Visual style.
size'sm' | 'md' | 'lg''md'Badge size.
countnumber—Number shown after the slot content, animated on change.
maxnumberInfinityLarger counts render as max+.
localestring—Locale for thousands separators.
livebooleanfalseAdds a pulsing dot (static with reduced motion).

Designed by Kuldeep Pisda · Released under the MIT License