Stats and dashboard widgets
The sixteen cards, rows and lists the dashboards are built from.
<script setup lang="ts">
import StatCard from '@shared/components/common/dashboard/StatCard.vue'
import { CreditCard, Package, TrendingUp, Users } from 'lucide-vue-next'
</script>
<template>
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="Revenue"
value="$48,210"
:icon="TrendingUp"
accent="emerald"
:trend="{ value: 12.4, direction: 'up' }"
caption="vs last month"
:series="[31, 42, 38, 55, 49, 61, 72]"
/>
<StatCard label="Orders" value="1,195" :icon="Package" accent="sky" :trend="{ value: 3.1, direction: 'down' }" caption="vs last month" />
<StatCard label="Customers" value="842" :icon="Users" accent="violet" />
<StatCard label="Refunds" value="$1,020" :icon="CreditCard" accent="rose" :trend="{ value: 0, direction: 'neutral' }" caption="flat" />
</div>
</template>The accent
Almost every widget here takes an accent, and the six are the same six everywhere: primary, sky, emerald, amber, violet, rose. It tints the icon tile, not the text.
In light mode the tiles use solid -50 tints, which stay true on a surface that is not white, where an alpha wash shifts with whatever is behind it. Dark mode keeps an alpha tint, because a literal -50 swatch glares on the card. primary stays on the theme token, so it follows a re-theme instead of drifting away from it.
Charts cannot use a Tailwind class, because a canvas resolves neither a class nor a custom property. accentHex(accent, themePrimary) hands back the drawable color, with primary resolved from the live theme.
Formatting is yours
Every figure these widgets take is a string you have already formatted, except where the type says a number. That is deliberate: the widget does not know whether 1200 is money, a count or a duration, and guessing produces $1,200 where you wanted 1,200 units.
The exceptions are StatsComparison, StatsProgress, FunnelBars and UpcomingList, which take numbers or dates because they do arithmetic or relative formatting with them.
Stat cards
Four card shapes for a KPI row. All four take loading, and all four take the accent.
StatsSimple
A label, a figure and an optional trend.
<script setup lang="ts">
import StatsSimple from '@shared/components/common/stats/StatsSimple.vue'
import { Package, Users, Wallet } from 'lucide-vue-next'
</script>
<template>
<div class="grid gap-4 sm:grid-cols-3">
<StatsSimple
title="Revenue"
value="$48,210"
:icon="Wallet"
:trend="{ value: 12.4, direction: 'up' }"
/>
<StatsSimple
title="Orders"
value="1,195"
:icon="Package"
accent="sky"
:trend="{ value: 3.1, direction: 'down' }"
/>
<StatsSimple
title="Customers"
value="842"
:icon="Users"
accent="violet"
:trend="{ value: 0, direction: 'neutral' }"
/>
</div>
</template>| Prop | Type | Description |
|---|---|---|
title | string | The label. |
value | string | number | The figure. |
icon | LucideIcon | Drawn in the accent tile. |
trend | { value: number, direction: 'up' | 'down' | 'neutral' } | The percentage is sign-independent; direction carries the sign. |
accent | StatAccent | Defaults to primary. |
loading | boolean | Skeleton instead of the card. |
StatsComparison
This period against the last. It works out the change itself, which is why value and previous are numbers.
<script setup lang="ts">
import StatsComparison from '@shared/components/common/stats/StatsComparison.vue'
import { ShoppingCart, Undo2 } from 'lucide-vue-next'
</script>
<template>
<div class="grid gap-4 sm:grid-cols-2">
<StatsComparison title="Orders" :value="1195" :previous="1042" :icon="ShoppingCart" accent="emerald" />
<StatsComparison title="Refunds" :value="38" :previous="61" :icon="Undo2" accent="rose" />
</div>
</template>| Prop | Type | Description |
|---|---|---|
title | string | The label. |
value | number | This period. |
previous | number | The one before. |
icon | LucideIcon | |
accent | StatAccent | |
loading | boolean |
StatsProgress
A figure against a target, with a bar.
<script setup lang="ts">
import StatsProgress from '@shared/components/common/stats/StatsProgress.vue'
import { Target, Users } from 'lucide-vue-next'
</script>
<template>
<div class="grid gap-4 sm:grid-cols-2">
<StatsProgress title="Quarterly target" :value="42800" :target="60000" :icon="Target" />
<StatsProgress title="Seats used" :value="34" :target="50" :icon="Users" accent="amber" />
</div>
</template>| Prop | Type | Description |
|---|---|---|
title | string | The label. |
value | number | Where you are. |
target | number | Where you are going. |
icon | LucideIcon | |
accent | StatAccent | |
loading | boolean |
StatsChart
The same card with a sparkline under it.
<script setup lang="ts">
import StatsChart from '@shared/components/common/stats/StatsChart.vue'
import { Activity, TrendingUp } from 'lucide-vue-next'
</script>
<template>
<div class="grid gap-4 sm:grid-cols-2">
<StatsChart
title="Revenue"
value="$48,210"
:series="[31, 42, 38, 55, 49, 61, 72]"
:icon="TrendingUp"
:trend="{ value: 12.4, direction: 'up' }"
/>
<StatsChart
title="Sessions"
value="18,904"
:series="[62, 58, 61, 49, 44, 40, 37]"
:icon="Activity"
accent="sky"
:trend="{ value: 8.2, direction: 'down' }"
/>
</div>
</template>| Prop | Type | Description |
|---|---|---|
title | string | The label. |
value | string | number | The figure. |
series | number[] | The sparkline's points. |
icon | LucideIcon | |
trend | { value, direction } | |
accent | StatAccent | |
loading | boolean |
Loading
<script setup lang="ts">
import StatsChart from '@shared/components/common/stats/StatsChart.vue'
import StatsComparison from '@shared/components/common/stats/StatsComparison.vue'
import StatsProgress from '@shared/components/common/stats/StatsProgress.vue'
import StatsSimple from '@shared/components/common/stats/StatsSimple.vue'
import { Button } from '@shared/components/ui/button'
import { Wallet } from 'lucide-vue-next'
import { ref } from 'vue'
const loading = ref(true)
</script>
<template>
<div class="space-y-4">
<Button size="sm" variant="outline" @click="loading = !loading">
{{ loading ? 'Show the cards' : 'Show the skeletons' }}
</Button>
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatsSimple title="Revenue" value="$48,210" :icon="Wallet" :loading="loading" />
<StatsComparison title="Orders" :value="1195" :previous="1042" :loading="loading" />
<StatsProgress title="Target" :value="42800" :target="60000" :loading="loading" />
<StatsChart title="Sessions" value="18,904" :series="[31, 42, 38, 55]" :loading="loading" />
</div>
</div>
</template>Sparkline
A bare trace, for putting inside something else. It has no chart library behind it.
<script setup lang="ts">
import Sparkline from '@shared/components/common/stats/Sparkline.vue'
</script>
<template>
<div class="flex flex-wrap items-center gap-8">
<div class="space-y-1">
<p class="text-xs text-muted-foreground">
Line only
</p>
<Sparkline :series="[31, 42, 38, 55, 49, 61, 72]" color="#1DB9C3" class="h-10 w-32" />
</div>
<div class="space-y-1">
<p class="text-xs text-muted-foreground">
With the area
</p>
<Sparkline :series="[31, 42, 38, 55, 49, 61, 72]" color="#10b981" area class="h-10 w-32" />
</div>
<div class="space-y-1">
<p class="text-xs text-muted-foreground">
Falling
</p>
<Sparkline :series="[72, 61, 49, 55, 38, 42, 31]" color="#f43f5e" area class="h-10 w-32" />
</div>
</div>
</template>| Prop | Type | Default | Description |
|---|---|---|---|
series | number[] | required | The points. |
color | string | required | A concrete color. Use accentHex() to resolve an accent. |
area | boolean | false | A soft fill under the line, and a y axis clamped to the data's own range. |
area is opt-in for both effects together. Eight pages outside the dashboards render this, and turning either on globally would restyle them. StatCard opts in, because at 76 by 34 pixels a bare hairline on a near-zero axis reads as a flat dash.
Give it a size on the element: it fills its box.
Dashboard widgets
StatCard
The dashboards' own KPI card: the accent tile, a trend row with a caption, and an optional sparkline.
The example at the top of this page is four of them.
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | required | |
value | string | number | required | |
icon | LucideIcon | — | |
accent | StatAccent | 'primary' | |
trend | { value, direction } | — | |
series | number[] | — | Omit for a card with no chart. |
caption | string | — | Trailing text in the trend row, such as "vs last month". |
HeroCard
The one card on a dashboard that is a filled block rather than a white one. An eyebrow, a headline figure, supporting copy, and either a progress ring or up to three supporting figures.
<script setup lang="ts">
import HeroCard from '@shared/components/common/dashboard/HeroCard.vue'
</script>
<template>
<div class="grid gap-4 lg:grid-cols-2">
<HeroCard
eyebrow="This quarter"
value="$482,100"
description="Across 1,204 invoices from 318 customers."
:metrics="[
{ label: 'Won', value: '68%' },
{ label: 'Average', value: '$400' },
{ label: 'Open', value: '112' },
]"
/>
<HeroCard
eyebrow="Target"
:value="71"
:percent="71"
accent="emerald"
description="$42,800 of $60,000 booked with three weeks to go."
/>
</div>
</template>| Prop | Type | Default | Description |
|---|---|---|---|
eyebrow | string | — | Small label above the headline. |
value | string | number | required | The headline. |
description | string | — | One or two lines. |
accent | StatAccent | 'primary' | A solid fill, one stop back in dark mode. |
percent | number | — | 0 to 100. The value renders inside a progress ring. |
metrics | { label, value }[] | — | Up to three figures along the bottom. |
SectionCard
The card every dashboard block sits in: a title, an optional subtitle, an optional action, and your content.
<script setup lang="ts">
import SectionCard from '@shared/components/common/dashboard/SectionCard.vue'
import SaListGroup from '@shared/components/common/list/SaListGroup.vue'
import SaListGroupItem from '@shared/components/common/list/SaListGroupItem.vue'
import { Button } from '@shared/components/ui/button'
</script>
<template>
<div class="grid gap-4 lg:grid-cols-2">
<SectionCard title="Revenue" subtitle="Last seven days">
<p class="text-3xl font-semibold tabular-nums">
$48,210
</p>
<p class="mt-1 text-sm text-muted-foreground">
Up 12.4% on the week before.
</p>
</SectionCard>
<SectionCard title="Latest invoices" subtitle="Newest first" flush>
<template #action>
<Button size="sm" variant="ghost">
See all
</Button>
</template>
<SaListGroup variant="flush">
<SaListGroupItem title="INV-2043" description="Northwind, $1,240" />
<SaListGroupItem title="INV-2044" description="Lumen Retail, $486" />
<SaListGroupItem title="INV-2045" description="Acme Health, $3,910" />
</SaListGroup>
</SectionCard>
</div>
</template>| Prop | Type | Default | Description |
|---|---|---|---|
title | string | required | |
subtitle | string | — | |
flush | boolean | false | Removes the body padding, for tables and edge-to-edge charts. |
Slots: default for the body, action for a control in the header.
It can shrink below its content's minimum width, so a card holding a wide table lets the table scroll rather than taking its own header off the screen. It also fills its grid cell, so a short card in a stretched row does not leave a band of dead space under it.
DashboardGreeting
The header the dashboards open with instead of a page title. It greets by time of day and by the signed-in user's first name, and it carries the same pin as the page header, because it replaces it.
<script setup lang="ts">
import DashboardGreeting from '@shared/components/common/dashboard/DashboardGreeting.vue'
import SaExportButton from '@shared/components/common/SaExportButton.vue'
</script>
<template>
<DashboardGreeting subtitle="Sales across every region, updated hourly.">
<template #actions>
<SaExportButton />
</template>
</DashboardGreeting>
</template>| Prop | Type | Description |
|---|---|---|
subtitle | string | One line under the greeting. Say what this dashboard is for. |
Slots: actions.
The greeting is one translation key per time band with the name as an argument, greeting.morningNamed and the rest, so each language writes the whole sentence its own way rather than gluing a name onto a fragment. With no signed-in user it falls back to the unnamed form, which is what the preview above shows.
The pin stores the route's title, not the greeting: "Good morning, Jane" would be a strange thing to find in a sidebar.
ActivityTimeline
A feed with a rail down the side.
<script setup lang="ts">
import type { TimelineEntry } from '@shared/components/common/dashboard/ActivityTimeline.vue'
import ActivityTimeline from '@shared/components/common/dashboard/ActivityTimeline.vue'
import { CheckCircle2, FileText, TriangleAlert, UserPlus } from 'lucide-vue-next'
const ENTRIES: TimelineEntry[] = [
{ id: '1', title: 'Invoice INV-2043 paid', description: 'Northwind, $1,240', time: '2h ago', icon: CheckCircle2, accent: 'emerald' },
{ id: '2', title: 'Warehouse near capacity', description: 'Felixstowe Hub at 96%', time: '5h ago', icon: TriangleAlert, accent: 'amber' },
{ id: '3', title: 'Quote sent', description: 'Lumen Retail, $8,400', time: 'Yesterday', icon: FileText, accent: 'sky' },
{ id: '4', title: 'Priya Raman joined', description: 'Support, London', time: '12 Aug', icon: UserPlus, accent: 'violet' },
]
</script>
<template>
<div class="max-w-md">
<ActivityTimeline :entries="ENTRIES" />
</div>
</template>entries is TimelineEntry[]: { id, title, description?, time?, icon?, accent? }. time is pre-formatted.
AssetRows
A holdings list: a ticker tile, a name, a price, a 24-hour move and an optional trace.
<script setup lang="ts">
import type { AssetRow } from '@shared/components/common/dashboard/AssetRows.vue'
import AssetRows from '@shared/components/common/dashboard/AssetRows.vue'
const ROWS: AssetRow[] = [
{ id: 'btc', symbol: 'BTC', name: 'Bitcoin', price: '$64,120', change: 2.4, holding: '0.482 BTC', series: [58, 61, 59, 64, 62, 66, 64] },
{ id: 'eth', symbol: 'ETH', name: 'Ethereum', price: '$3,088', change: -1.2, holding: '6.10 ETH', series: [33, 32, 34, 31, 30, 31, 30] },
{ id: 'sol', symbol: 'SOL', name: 'Solana', price: '$148.60', change: 5.8, series: [12, 13, 12, 14, 13, 15, 15] },
{ id: 'ada', symbol: 'ADA', name: 'Cardano', price: '$0.62', change: 0, holding: '1,200 ADA' },
]
</script>
<template>
<div class="max-w-lg">
<AssetRows :rows="ROWS" />
</div>
</template>rows is AssetRow[]: { id, symbol, name, price, change, holding?, series? }. price is pre-formatted; change is a number whose sign carries the direction.
FunnelBars
Stages that narrow, with the drop between them.
<script setup lang="ts">
import type { FunnelStage } from '@shared/components/common/dashboard/FunnelBars.vue'
import FunnelBars from '@shared/components/common/dashboard/FunnelBars.vue'
const STAGES: FunnelStage[] = [
{ label: 'Visited', value: 18904 },
{ label: 'Signed up', value: 4210 },
{ label: 'Activated', value: 2180 },
{ label: 'Paid', value: 842 },
]
</script>
<template>
<div class="max-w-lg">
<FunnelBars :stages="STAGES" />
</div>
</template>| Prop | Type | Default | Description |
|---|---|---|---|
stages | FunnelStage[] | required | { label, value, color? }. Without a color the bars walk a preset ramp. |
formatter | (value: number) => string | formatNumber | How each stage's value is printed. |
Leaderboard
A ranked list of people, with a bar and an avatar.
<script setup lang="ts">
import type { LeaderboardEntry } from '@shared/components/common/dashboard/Leaderboard.vue'
import Leaderboard from '@shared/components/common/dashboard/Leaderboard.vue'
const ENTRIES: LeaderboardEntry[] = [
{ id: '1', name: 'Lina Haddad', meta: 'Enterprise, EMEA', value: '$182,400', percent: 100, avatar: '/demo/avatars/lina-haddad.svg' },
{ id: '2', name: 'Marcus Webb', meta: 'Mid-market, EMEA', value: '$146,900', percent: 81, avatar: '/demo/avatars/marcus-webb.svg' },
{ id: '3', name: 'Yuki Tanaka', meta: 'Enterprise, APAC', value: '$121,300', percent: 67, avatar: '/demo/avatars/yuki-tanaka.svg' },
{ id: '4', name: 'Tomas Ferreira', meta: 'SMB, LATAM', value: '$88,050', percent: 48, avatar: '/demo/avatars/tomas-ferreira.svg' },
{ id: '5', name: 'Oscar Lindqvist', meta: 'SMB, EMEA', value: '$61,700', percent: 34, avatar: '/demo/avatars/oscar-lindqvist.svg' },
]
</script>
<template>
<div class="max-w-lg">
<Leaderboard :entries="ENTRIES" />
</div>
</template>entries is LeaderboardEntry[]: { id, name, meta?, value, percent, avatar? }. With no avatar the tile shows the name's initials.
ProjectRows
Projects with a status chip, a progress bar, a due date and a stack of members.
<script setup lang="ts">
import type { ProjectRow } from '@shared/components/common/dashboard/ProjectRows.vue'
import ProjectRows from '@shared/components/common/dashboard/ProjectRows.vue'
const ROWS: ProjectRow[] = [
{
id: '1',
name: 'Warehouse rollout',
meta: 'Northwind',
status: 'on-track',
progress: 68,
due: '10 Apr',
members: [
{ name: 'Lina Haddad', avatar: '/demo/avatars/lina-haddad.svg' },
{ name: 'Marcus Webb', avatar: '/demo/avatars/marcus-webb.svg' },
{ name: 'Yuki Tanaka', avatar: '/demo/avatars/yuki-tanaka.svg' },
{ name: 'Tomas Ferreira', avatar: '/demo/avatars/tomas-ferreira.svg' },
],
},
{
id: '2',
name: 'Checkout redesign',
meta: 'Lumen Retail',
status: 'at-risk',
progress: 42,
due: '28 Mar',
members: [
{ name: 'Sara Mansour', avatar: '/demo/avatars/sara-mansour.svg' },
{ name: 'Ravi Menon', avatar: '/demo/avatars/ravi-menon.svg' },
],
},
{
id: '3',
name: 'Billing migration',
meta: 'Acme Health',
status: 'done',
progress: 100,
due: '14 Feb',
members: [
{ name: 'Chloe Dubois', avatar: '/demo/avatars/chloe-dubois.svg' },
],
},
{
id: '4',
name: 'Driver app',
meta: 'Vellum Logistics',
status: 'delayed',
progress: 17,
due: '1 Jun',
members: [
{ name: 'Elias Novak', avatar: '/demo/avatars/elias-novak.svg' },
{ name: 'Imani Brooks', avatar: '/demo/avatars/imani-brooks.svg' },
],
},
]
</script>
<template>
<div class="max-w-2xl">
<ProjectRows :rows="ROWS" />
</div>
</template>| Prop | Type | Default | Description |
|---|---|---|---|
rows | ProjectRow[] | required | { id, name, meta?, status, progress, members, due? }. |
maxAvatars | number | 3 | Members shown before the rest collapse into a "+N" chip. |
status is on-track, at-risk, delayed or done, and it colors both the chip and the bar.
RecordRows
The general-purpose row list: invoices, transactions, tickets, shipments.
<script setup lang="ts">
import type { RecordRow } from '@shared/components/common/dashboard/RecordRows.vue'
import RecordRows from '@shared/components/common/dashboard/RecordRows.vue'
import { ArrowDownLeft, ArrowUpRight, Receipt } from 'lucide-vue-next'
const ROWS: RecordRow[] = [
{ id: '1', title: 'Northwind', meta: 'INV-2043', value: '+$1,240.00', valueMeta: '2h ago', valueTone: 'success', icon: ArrowDownLeft, accent: 'emerald', status: { label: 'Paid', tone: 'success' } },
{ id: '2', title: 'Cloud hosting', meta: 'Monthly', value: '-$318.40', valueMeta: 'Yesterday', valueTone: 'danger', icon: ArrowUpRight, accent: 'rose' },
{ id: '3', title: 'Lumen Retail', meta: 'INV-2044', value: '+$486.00', valueMeta: '3 days ago', icon: Receipt, accent: 'sky', status: { label: 'Pending', tone: 'warning' } },
{ id: '4', title: 'Priya Raman', meta: 'Expense claim', value: '+$3,910.00', valueMeta: 'Last week', avatar: '/demo/avatars/priya-raman.svg', status: { label: 'Overdue', tone: 'danger' } },
]
</script>
<template>
<div class="max-w-lg">
<RecordRows :rows="ROWS" />
</div>
</template>| Prop | Type | Default | Description |
|---|---|---|---|
rows | RecordRow[] | required | See below. |
divided | boolean | true | Separators. Off for a short list inside a small card. |
RecordRow is { id, title, meta?, value?, valueMeta?, status?, icon?, avatar?, accent?, valueTone? }. The leading tile prefers an icon, then an avatar, then the title's initials. status.tone and valueTone are neutral, info, success, warning or danger, and valueTone is what tints a credit green and a debit red.
UpcomingList
What is coming, with the date rendered in the app's language.
<script setup lang="ts">
import type { UpcomingItem } from '@shared/components/common/dashboard/UpcomingList.vue'
import UpcomingList from '@shared/components/common/dashboard/UpcomingList.vue'
const ITEMS: UpcomingItem[] = [
{ id: '1', title: 'Quarterly review', meta: 'Amina Haddad', date: new Date(2026, 3, 2, 10, 0), accent: 'primary' },
{ id: '2', title: 'Warehouse handover', meta: 'Felixstowe Hub', date: new Date(2026, 3, 9, 14, 30), accent: 'amber' },
{ id: '3', title: 'Contract renewal', meta: 'Lumen Retail', date: new Date(2026, 3, 21, 9, 0), accent: 'emerald' },
]
</script>
<template>
<div class="max-w-md">
<UpcomingList :items="ITEMS" />
</div>
</template>items is UpcomingItem[]: { id, title, meta?, date, accent? }. date is a real Date, because this is the one widget that formats it for you, in the active language.
Accessibility
- Every icon tile is decorative; the label beside it carries the meaning.
- A trend badge carries an arrow as well as its color, so up, down and flat are distinguishable without color. The arrow is decorative, so a screen reader reads the percentage on its own; put the direction in the
captionwhere that matters. - Progress bars and funnel bars are presentational: the figure they stand for is printed beside them, so the bar adds nothing a screen reader needs.
- Figures use tabular numerals, so a value that updates does not make the row jitter, and a percentage carries
dir="ltr", because its leading digit is bidi-neutral and would otherwise move in a right-to-left layout. - Titles and subtitles carry
dir="auto", so a record's own name resolves its direction from its own first strong character.