rapidUI

Stat Card

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.json

Installs to @/components/ui/stat-card.

Manual installation

Install the dependencies:

pnpm add @base-ui/react@^1.8.0 lucide-react@^1.48.0

Copy the source into your project:

components/ui/stat-card.tsx
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, StatCardFooter and StatCardIcon
  • StatCardTrend shows the direction (up, down or flat) with an icon, and its colour follows meaning: goodDirection says 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
  • render turns the whole card into a link, for example to a filtered case list
  • StatCardGroup lays cards out in a responsive grid that wraps on narrow screens
  • data-slot attributes 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.

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.

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 like Card; accepts render and all div attributes
  • StatCardGroup - A responsive grid for a row of cards
  • StatCardLabel - What the figure is, e.g. "Open cases"
  • StatCardValue - The figure itself, in tabular numbers
  • StatCardUnit - A smaller, muted unit after the value, e.g. "days" or "kr"
  • StatCardTrend - The change, with a direction icon and a colour that follows meaning
  • StatCardDescription - The comparison, e.g. "vs last week"
  • StatCardFooter - Lays out the trend and description on one line
  • StatCardIcon - A decorative icon in the top-right corner

StatCardTrend props

PropTypeDefaultDescription
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 }EnglishScreen-reader text for the direction and its meaning; override to translate
childrenReactNode-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; StatCardTrend adds 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' }}
  • StatCardIcon is 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
  • Card - General-purpose container
  • Chart - For trends over time
  • Skeleton - Loading placeholders
  • Badge - Short status labels