A key figure with a label, a trend and a comparison, for dashboards and overviews.
Installation
pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/stat-card.jsonInstalls to @/components/ui/stat-card.
Manual installation
Install the dependencies:
pnpm add @base-ui/react@^1.8.0 lucide-react@^1.48.0Copy the source into your project:
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { MinusIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function StatCardGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="stat-card-group"
className={cn('grid grid-cols-[repeat(auto-fit,minmax(min(100%,13rem),1fr))] gap-4', className)}
{...props}
/>
)
}
function StatCard({ className, render, ...props }: useRender.ComponentProps<'div'>) {
return useRender({
defaultTagName: 'div',
props: mergeProps<'div'>(
{
className: cn(
'group/stat-card relative flex min-w-0 flex-col gap-1 rounded-xl bg-card p-4 text-sm text-card-foreground ring-1 ring-border outline-none transition-colors has-data-[slot=stat-card-icon]:pr-12 focus-visible:ring-3 focus-visible:ring-ring [a]:hover:bg-muted/50',
className,
),
},
props,
),
render,
state: {
slot: 'stat-card',
},
})
}
function StatCardLabel({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="stat-card-label"
className={cn('text-sm leading-snug font-medium text-muted-foreground', className)}
{...props}
/>
)
}
function StatCardIcon({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="stat-card-icon"
aria-hidden="true"
className={cn(
"absolute top-3 right-3 flex size-8 items-center justify-center rounded-lg bg-muted text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function StatCardValue({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="stat-card-value"
className={cn('font-heading text-2xl leading-tight font-semibold tabular-nums', className)}
{...props}
/>
)
}
function StatCardUnit({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="stat-card-unit"
className={cn('ml-1 text-sm font-normal text-muted-foreground', className)}
{...props}
/>
)
}
type StatCardTrendDirection = 'up' | 'down' | 'flat'
type StatCardTrendTone = 'good' | 'bad' | 'neutral'
const defaultTrendLabels = {
up: 'Increase',
down: 'Decrease',
flat: 'No change',
good: 'good',
bad: 'bad',
}
type StatCardTrendLabels = typeof defaultTrendLabels
const trendIcons = {
up: TrendingUpIcon,
down: TrendingDownIcon,
flat: MinusIcon,
} satisfies Record<StatCardTrendDirection, React.ComponentType<React.SVGProps<SVGSVGElement>>>
function getTrendTone(direction: StatCardTrendDirection, goodDirection: 'up' | 'down' | 'none'): StatCardTrendTone {
if (direction === 'flat' || goodDirection === 'none') return 'neutral'
return direction === goodDirection ? 'good' : 'bad'
}
function StatCardTrend({
className,
direction,
goodDirection = 'up',
labels,
children,
...props
}: React.ComponentProps<'p'> & {
/** Which way the figure moved */
direction: StatCardTrendDirection
/**
* Which direction is an improvement. `up` for e.g. decided cases, `down` for e.g. overdue cases or handling time,
* `none` when the change is neither good nor bad. The colour follows from this.
*/
goodDirection?: 'up' | 'down' | 'none'
/** Screen-reader text for the direction and its meaning; English defaults, override to translate */
labels?: Partial<StatCardTrendLabels>
}) {
const tone = getTrendTone(direction, goodDirection)
const text = { ...defaultTrendLabels, ...labels }
const Icon = trendIcons[direction]
const srText = tone === 'neutral' ? text[direction] : `${text[direction]}, ${text[tone]}`
return (
<p
data-slot="stat-card-trend"
data-direction={direction}
data-tone={tone}
className={cn(
'inline-flex w-fit items-center gap-1 text-sm font-medium tabular-nums data-[tone=bad]:text-destructive-subtle-foreground data-[tone=good]:text-success-subtle-foreground data-[tone=neutral]:text-muted-foreground [&>svg]:pointer-events-none [&>svg]:size-4 [&>svg]:shrink-0',
className,
)}
{...props}
>
<Icon aria-hidden="true" />
<span className="sr-only">{srText}: </span>
{children}
</p>
)
}
function StatCardDescription({ className, ...props }: React.ComponentProps<'p'>) {
return <p data-slot="stat-card-description" className={cn('text-sm text-muted-foreground', className)} {...props} />
}
function StatCardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="stat-card-footer"
className={cn('mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-0.5', className)}
{...props}
/>
)
}
export {
StatCard,
StatCardGroup,
StatCardLabel,
StatCardIcon,
StatCardValue,
StatCardUnit,
StatCardTrend,
StatCardDescription,
StatCardFooter,
getTrendTone,
type StatCardTrendDirection,
type StatCardTrendLabels,
type StatCardTrendTone,
}A key figure with a label, a value, an optional trend and a comparison, such as the open cases, cases due this week
and average handling time at the top of a case officer's overview. It is styled like Card and composed from small
parts, so you only use the ones you need.
Features
- Composable
StatCardLabel,StatCardValue,StatCardUnit,StatCardTrend,StatCardDescription,StatCardFooterandStatCardIcon StatCardTrendshows the direction (up,downorflat) with an icon, and its colour follows meaning:goodDirectionsays whether up or down is an improvement- The direction and whether it is good or bad are also given as screen-reader text, so nothing relies on colour alone
- Values use tabular numbers, so figures line up in a row of cards
renderturns the whole card into a link, for example to a filtered case listStatCardGrouplays cards out in a responsive grid that wraps on narrow screensdata-slotattributes on every part
Usage
import { FolderOpenIcon } from 'lucide-react'
import {
StatCard,
StatCardDescription,
StatCardFooter,
StatCardIcon,
StatCardLabel,
StatCardTrend,
StatCardValue,
} from '@/components/ui/stat-card'
export function OpenCases() {
return (
<StatCard>
<StatCardLabel>Open cases</StatCardLabel>
<StatCardIcon>
<FolderOpenIcon />
</StatCardIcon>
<StatCardValue>14</StatCardValue>
<StatCardFooter>
{/* More open cases is bad, so the trend is shown in the destructive colour */}
<StatCardTrend direction="up" goodDirection="down">
+2
</StatCardTrend>
<StatCardDescription>vs last week</StatCardDescription>
</StatCardFooter>
</StatCard>
)
}Examples
Default
A single key figure with a trend and a comparison.
Case Overview
Four cards in a StatCardGroup for a case officer's workload. Wrap the group in a section with an aria-label so
it shows up as a landmark.
Trends
Good, bad and neutral trends. More decided cases is good (goodDirection="up", the default), more overdue cases is bad
(goodDirection="down"), the number of applications received is neither (goodDirection="none"), and direction="flat"
means no change.
With Icon
StatCardIcon places a decorative icon in the top-right corner. Tint it with theme tokens in className.
Loading
Put a Skeleton in place of the value and trend while the figures load, and mark the card with aria-busy.
As Link
Pass render={<a href="…" />} to make the whole card a link, for example to the case list filtered on overdue cases.
It gets a hover background and a visible focus ring.
Subcomponents
StatCard- The container, styled likeCard; acceptsrenderand alldivattributesStatCardGroup- A responsive grid for a row of cardsStatCardLabel- What the figure is, e.g. "Open cases"StatCardValue- The figure itself, in tabular numbersStatCardUnit- A smaller, muted unit after the value, e.g. "days" or "kr"StatCardTrend- The change, with a direction icon and a colour that follows meaningStatCardDescription- The comparison, e.g. "vs last week"StatCardFooter- Lays out the trend and description on one lineStatCardIcon- A decorative icon in the top-right corner
StatCardTrend props
| Prop | Type | Default | Description |
|---|---|---|---|
direction | 'up' | 'down' | 'flat' | - | Which way the figure moved; sets the icon |
goodDirection | 'up' | 'down' | 'none' | 'up' | Which direction is an improvement; sets the colour |
labels | { up?: string, down?: string, flat?: string, good?: string, bad?: string } | English | Screen-reader text for the direction and its meaning; override to translate |
children | ReactNode | - | The change as text, e.g. "+12%" |
Good trends use text-success, bad trends text-destructive and neutral or flat trends text-muted-foreground. The
resulting tone is also set as data-tone (good, bad or neutral) for styling.
Accessibility
- The trend icon is decorative;
StatCardTrendadds screen-reader text such as "Increase, bad:" before the change, so the direction and its meaning do not rely on colour alone - Translate the screen-reader text with
labels, e.g.labels={{ up: 'Ökning', down: 'Minskning', flat: 'Oförändrat', good: 'bra', bad: 'dåligt' }} StatCardIconis hidden from assistive technology; the label carries the meaning- Write the change so it reads well on its own, e.g. "+12%" or "3 days faster than August", and put the comparison in
StatCardDescription - A card rendered as a link has a visible focus ring; make sure its text describes where it goes, e.g. "Show overdue cases"
- While loading, add
aria-busy="true"and screen-reader text such as "Loading" in place of the value
Related Components
- Card - General-purpose container
- Chart - For trends over time
- Skeleton - Loading placeholders
- Badge - Short status labels