Compact KPI card: a metric name, a large tabular value, an optional outline badge with a trend-up or trend-down icon and screen-reader label, and an optional description line.
import type { ComponentProps, ReactNode } from "react"
import { TrendDownIcon, TrendUpIcon } from "@phosphor-icons/react/dist/ssr"
import { Badge } from "@/components/ui/badge"
import {
Card,
CardAction,
CardDescription,
CardHeader,
} from "@/components/ui/card"
type MetricCardProps = Omit<
ComponentProps<typeof Card>,
"title" | "children" | "size"
> & {
/**
* The stock `Card` size: `default` puts 24px of space around and between
* the card's parts, `sm` 16px.
*/
size?: ComponentProps<typeof Card>["size"]
/** The metric's name, shown as a small muted label at the top. */
name: ReactNode
/** The figure itself, in large type with tabular numerals. */
value: ReactNode
/**
* A change badge in the top-right corner. `value` is its visible text,
* `trend` picks an up or down arrow, and `label` is read to screen readers
* before the value, since the arrow is hidden from them.
*/
change?: { value: ReactNode; trend: "up" | "down"; label: string }
/** A line of muted text under the value. */
description?: ReactNode
}
/**
* A card that shows one metric on a dashboard: its name, its value in large
* type, an optional change badge and an optional line of text below.
*
* It takes no children. `className` and other props land on the `Card`.
*/
function MetricCard({
name,
value,
change,
description,
...props
}: MetricCardProps) {
return (
<Card data-slot="metric-card" {...props}>
<CardHeader>
<CardDescription>{name}</CardDescription>
{change && (
<CardAction>
<Badge variant="outline">
{change.trend === "up" ? (
<TrendUpIcon
weight="light"
aria-hidden="true"
data-icon="inline-start"
/>
) : (
<TrendDownIcon
weight="light"
aria-hidden="true"
data-icon="inline-start"
/>
)}
<span className="sr-only">{change.label}</span>
{change.value}
</Badge>
</CardAction>
)}
<p className="text-2xl font-medium tabular-nums">{value}</p>
{description != null && (
<CardDescription>{description}</CardDescription>
)}
</CardHeader>
</Card>
)
}
export { MetricCard }
export type { MetricCardProps }
export default MetricCard
pnpm dlx shadcn add @01orbital/metric-cardnpx shadcn add @01orbital/metric-cardyarn shadcn add @01orbital/metric-cardbunx --bun shadcn add @01orbital/metric-cardFree under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.
It lands at components/analytics/metric-card.tsx, as source you own and edit.
import { MetricCard } from "@/components/analytics/metric-card"
// The demo's words. Change copy here, not in the markup.
const COPY = {
name: "Total visitors",
value: "248.5k",
change: "12.4%",
up: "Up",
period: "vs last period",
} as const
export default function MetricCardDemo() {
return (
<>
{/* 276px: one column of the four-up dashboard row this card is made
for (72rem less three 16px gaps, over four). The card sets its own
width: a centred grid item otherwise shrinks to its content. */}
<MetricCard
className="w-full max-w-[17.25rem]"
name={COPY.name}
value={COPY.value}
change={{ value: COPY.change, trend: "up", label: COPY.up }}
description={COPY.period}
/>
</>
)
}Prop
Type
It also accepts the HTML attributes of the element it renders.
npm: @phosphor-icons/react. The CLI installs them for you.
Registry pieces it installs with it:
badgecardNavbar
Site header built on Navigation Menu in left, centered-links and centered-logo layouts. Container queries hide links on narrow widths and wrap actions; parts include logo, links and actions.
Rating
Star rating on Base UI RadioGroup with hover preview and three sizes. Without a change handler, or when read-only, it renders as a labelled image of whole stars; star labels are translatable.