Vue componentFree

Stats Cards

Vue 3 KPI stat cards with trend deltas and tiny inline SVG sparklines, no chart library needed. Tailwind CSS, dark mode, typed props.

by Vueplayv1.0.0Updated 3 hours ago

Install
Full screen โ†—

About this component

A row of key metrics for the top of a dashboard: each card shows a label, the current value, a change badge and a small sparkline of recent data points. The sparklines are plain SVG paths computed in the component, so there is no charting dependency to install.

Delta badges know which direction is good. Mark metrics like churn or response time with goodWhen: 'down' and a decrease turns green while an increase turns red. Screen readers hear the direction, the size of the change and whether it is good, and each sparkline has a text label describing its range.

Pass pre-formatted values so you stay in control of currency and units, and use the actions slot for a date range picker or export button. The grid goes from one column on phones to two on tablets and four on wide screens.

  • Inline SVG sparklines with gradient fill
  • Direction-aware good/bad delta colours
  • Accessible labels for deltas and charts
  • Actions slot for filters
  • Light and dark mode

Code

11 files in the repo
<script setup lang="ts">
import { computed, useId } from 'vue'

export interface Stat {
  label: string
  /** Display value, already formatted (e.g. "$48,210" or "3.4%"). */
  value: string
  /** Change versus the previous period, in percent. Positive or negative. */
  delta: number
  /** Data points for the sparkline, oldest first. Two or more points. */
  trend: number[]
  /** Set to 'down' for metrics where a decrease is good (churn, response time). */
  goodWhen?: 'up' | 'down'
}

const props = withDefaults(defineProps<{
  stats?: Stat[]
  heading?: string
  /** Shown under the heading and used in the delta's accessible label. */
  period?: string
}>(), {
  heading: 'Overview',
  period: 'Last 30 days',
  stats: () => [
    { label: 'Monthly recurring revenue', value: '$48,210', delta: 12.4, trend: [31, 33, 32, 35, 36, 38, 37, 40, 42, 41, 45, 48] },
    { label: 'Active workspaces', value: '2,914', delta: 4.1, trend: [2410, 2480, 2520, 2500, 2610, 2650, 2700, 2690, 2760, 2810, 2870, 2914] },
    { label: 'Churn rate', value: '1.9%', delta: -0.6, goodWhen: 'down', trend: [2.8, 2.7, 2.6, 2.6, 2.4, 2.5, 2.3, 2.2, 2.1, 2.1, 2.0, 1.9] },
    { label: 'Avg. first response', value: '2h 14m', delta: 8.3, goodWhen: 'down', trend: [118, 122, 120, 126, 125, 131, 128, 129, 134, 131, 136, 134] },
  ],
})

const uid = useId()

const W = 120
const H = 36

function paths(points: number[]) {
  const min = Math.min(...points)
  const max = Math.max(...points)
  const span = max - min || 1
  const step = points.length > 1 ? W / (points.length - 1) : 0
  // Leave 3px of room at the top and bottom so the stroke is never clipped.
  const xy = points.map((p, i) => [i * step, H - 3 - ((p - min) / span) * (H - 6)] as const)
  const line = xy.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`).join(' ')
  return { line, area: `${line} L${W} ${H} L0 ${H} Z` }
}

const cards = computed(() => props.stats.map((stat, i) => {
  const up = stat.delta >= 0
  const good = stat.delta === 0 ? null : (stat.goodWhen ?? 'up') === 'up' ? up : !up
  return { stat, up, good, id: `${uid}-g${i}`, ...paths(stat.trend) }
}))

const fmtDelta = (d: number) => `${d > 0 ? '+' : d < 0 ? 'โˆ’' : ''}${Math.abs(d).toFixed(1)}%`
</script>

<template>
  <section>
    <div class="flex flex-wrap items-end justify-between gap-4">
      <div>
        <h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ heading }}</h2>
        <p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">{{ period }}</p>
      </div>
      <slot name="actions" />
    </div>

    <dl class="mt-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
      <div
        v-for="c in cards"
        :key="c.stat.label"
        class="relative flex flex-col overflow-hidden rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:shadow-none dark:ring-white/10"
      >
        <dt class="text-sm font-medium text-gray-600 dark:text-gray-400">{{ c.stat.label }}</dt>
        <dd class="mt-2 flex items-baseline justify-between gap-3">
          <span class="text-3xl font-semibold tracking-tight text-gray-900 tabular-nums dark:text-white">{{ c.stat.value }}</span>
          <span
            class="inline-flex shrink-0 items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums"
            :class="c.good === null
              ? 'bg-gray-100 text-gray-600 dark:bg-white/10 dark:text-gray-300'
              : c.good
                ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-300'
                : 'bg-red-50 text-red-700 dark:bg-red-400/10 dark:text-red-300'"
          >
            <svg v-if="c.stat.delta !== 0" viewBox="0 0 12 12" aria-hidden="true" class="size-3 fill-current" :class="!c.up && 'rotate-180'">
              <path d="M6 2.5 10 8H2l4-5.5Z" />
            </svg>
            <span class="sr-only">{{ c.up ? 'Increased' : 'Decreased' }} by</span>
            {{ fmtDelta(c.stat.delta) }}
            <span class="sr-only">compared to the previous period ({{ c.good === null ? 'no change' : c.good ? 'good' : 'needs attention' }})</span>
          </span>
        </dd>
        <dd class="mt-4">
          <svg
            :viewBox="`0 0 ${W} ${H}`"
            preserveAspectRatio="none"
            class="h-10 w-full overflow-visible text-blue-600 dark:text-blue-400"
            role="img"
            :aria-label="`Trend for ${c.stat.label}, ${c.stat.trend.length} data points, from ${c.stat.trend[0]} to ${c.stat.trend[c.stat.trend.length - 1]}`"
          >
            <defs>
              <linearGradient :id="c.id" x1="0" x2="0" y1="0" y2="1">
                <stop offset="0%" stop-color="currentColor" stop-opacity="0.25" />
                <stop offset="100%" stop-color="currentColor" stop-opacity="0" />
              </linearGradient>
            </defs>
            <g>
              <path :d="c.area" :fill="`url(#${c.id})`" />
              <path :d="c.line" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke" />
            </g>
          </svg>
        </dd>
      </div>
    </dl>
  </section>
</template>

Props

PropTypeDefaultDescription
statsStat[]MRR, workspaces, churn, response timeMetrics with label, formatted value, delta (percent), trend (numbers, oldest first) and optional goodWhen ('up' | 'down').
headingstring'Overview'Heading above the cards.
periodstring'Last 30 days'Period label shown under the heading.

Slots

SlotDescription
actionsContent on the right of the heading, e.g. a date range picker.
All data display โ†’
Preview of BoardFree

Component ยท Data display

Board

A flexible board component built for smooth panning, zooming with a grid layout. It supports both mouse and touch input, making it a great fit for whiteboards, diagram tools, mind maps, and workflow editors. Designed for performance and responsiveness, it delivers a fluid experience on desktop and mobile with intuitive gestures and real-time interaction on a scalable canvas.

Preview of Changelog TimelineFree

Component ยท Data display

Changelog Timeline

Vertical changelog timeline for Vue 3 with version badges, dated releases, typed change tags and filter chips. Built with Tailwind CSS, dark mode.

by Vueplay
Preview of Kanban Board$12

Component ยท Data display

Kanban Board

Vue 3 kanban board with native drag and drop, keyboard moves, WIP limits and inline add card. v-model board state, Tailwind CSS, dark mode.

by Vueplay
Preview of Team GridFree

Component ยท Data display

Team Grid

Vue 3 team section with a responsive grid of member cards: initials avatars, role, short bio, location and social links. Tailwind CSS, dark mode.

by Vueplay

Pro feature

Upgrade to Pro

Upgrade to unlock more of Vueplay. Cancel any time.

  • Custom domains with HTTPS
  • Private projects
  • No "Made with Vueplay" badge
  • GitHub sync

$120/year

Compare plansBilling settings