Patterns
Metric Card
A card for one KPI number, with a trend, a usage bar or a chart.
import { MetricCard, MetricTrend, UsageBar } from 'ferry-ui'
export default function MetricCardHero() {
return (
<div className="grid gap-4 sm:grid-cols-2">
<MetricCard label="Revenue" value="$48,200" trend={<MetricTrend>+12.5%</MetricTrend>} hint="Last 30 days" />
<MetricCard
label="Active users"
value="3,912"
trend={<MetricTrend direction="down">-2.1%</MetricTrend>}
hint="Last 7 days"
/>
<MetricCard label="Orders" value="1,284" hint="Last 30 days" />
<MetricCard label="Storage" value="46 GB" unit="of 50 GB">
<UsageBar value={92} label="Storage used" />
</MetricCard>
</div>
)
}grid gap-4 sm:grid-cols-2 lg:grid-cols-4 for a row of four cards.value as you give it.Import the parts and put them together.
import { LegendDot, MetricCard, MetricTrend, UsageBar } from 'ferry-ui'
<MetricCard label="" value="" trend={<MetricTrend />} aside={<LegendDot />}>
<UsageBar value={0} label="" />
</MetricCard>| Part | Role |
|---|---|
MetricCard | The card: a label, a value and room for content below. |
MetricTrend | A change, with an arrow and a color. It goes in trend. |
UsageBar | A thin bar for a percentage from 0 to 100. |
LegendDot | A dot and the name of a chart series. It goes in aside. |
direction sets the arrow of MetricTrend. sentiment sets the color. If an increase is bad, set sentiment="negative".
import { MetricCard, MetricTrend } from 'ferry-ui'
export default function MetricCardTrend() {
return (
<div className="grid gap-4 sm:grid-cols-3">
<MetricCard label="Revenue" value="$48,200" trend={<MetricTrend direction="up">+12.5%</MetricTrend>} />
<MetricCard label="Open tickets" value="14" trend={<MetricTrend direction="flat">0.0%</MetricTrend>} />
<MetricCard
label="Churn rate"
value="3.1%"
trend={
<MetricTrend direction="up" sentiment="negative">
+0.4 pts
</MetricTrend>
}
/>
</div>
)
}| Direction | Default sentiment |
|---|---|
up (default) | positive, green |
down | negative, red |
flat | neutral, muted |
Put a UsageBar in the children of the card to show a quota. With the default auto tone, the bar changes to warning at 75 and to destructive at 90. warningAt and destructiveAt change these limits.
import { MetricCard, UsageBar } from 'ferry-ui'
const QUOTAS = [
{ label: 'API calls', value: '320k', unit: 'of 1M', percent: 32 },
{ label: 'Seats', value: '8', unit: 'of 10', percent: 80 },
{ label: 'Storage', value: '47 GB', unit: 'of 50 GB', percent: 94 },
]
export default function MetricCardUsage() {
return (
<div className="grid gap-4 sm:grid-cols-3">
{QUOTAS.map((quota) => (
<MetricCard key={quota.label} label={quota.label} value={quota.value} unit={quota.unit}>
<UsageBar value={quota.percent} label={`${quota.label} used`} />
</MetricCard>
))}
</div>
)
}info adds a button after the label. Its Tooltip shows the text on hover and on keyboard focus. A touch screen cannot show it. Do not put necessary information there.
The tooltip needs the TooltipProvider of the app. See Quick start.
import { MetricCard } from 'ferry-ui'
export default function MetricCardInfo() {
return (
<MetricCard
className="w-full max-w-xs"
label="Error rate"
value="0.42%"
hint="Last 24 hours"
info="The share of API requests that failed."
infoLabel="About the error rate"
/>
)
}aside shows content on the right of the label. Use LegendDot there to name a chart series. Put the chart in the children.
import { LegendDot, MetricCard } from 'ferry-ui'
// Requests for each hour, in thousands.
const REQUESTS = [42, 38, 31, 26, 22, 24, 35, 58, 74, 88, 92, 86, 95, 99, 93, 80, 64, 49]
export default function MetricCardLegend() {
const peak = Math.max(...REQUESTS)
return (
<MetricCard
className="w-full max-w-sm"
label="API requests"
value="1.1M"
hint="Last 18 hours"
aside={<LegendDot tone="brand">Requests</LegendDot>}
>
<div
role="img"
aria-label={`Requests for each hour. The peak is ${peak}k.`}
className="flex h-12 items-end gap-1"
>
{REQUESTS.map((count, index) => (
<span key={index} className="flex-1 rounded-sm bg-brand" style={{ height: `${(count / peak) * 100}%` }} />
))}
</div>
</MetricCard>
)
}Set loading while the value loads. The card shows a skeleton and hides unit, trend and hint. The children stay. Give them their own empty state.
import * as React from 'react'
import { Label, MetricCard, MetricTrend, Switch, UsageBar } from 'ferry-ui'
export default function MetricCardLoading() {
// In an app, `loading` comes from the request that loads the numbers.
const [loading, setLoading] = React.useState(true)
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Switch id="metrics-loading" checked={loading} onCheckedChange={setLoading} />
<Label htmlFor="metrics-loading">Loading</Label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<MetricCard
label="Revenue"
value="$48,200"
trend={<MetricTrend>+12.5%</MetricTrend>}
hint="Last 30 days"
loading={loading}
/>
<MetricCard label="Storage" value="46 GB" unit="of 50 GB" loading={loading}>
<UsageBar value={loading ? null : 92} label="Storage used" />
</MetricCard>
</div>
</div>
)
}compact changes the padding from 20px to 16px. Use it for a dense grid of small cards.
import { MetricCard } from 'ferry-ui'
export default function MetricCardCompact() {
return (
<div className="grid gap-4 sm:grid-cols-3">
<MetricCard compact label="Open tickets" value="14" />
<MetricCard compact label="First reply" value="1h 12m" />
<MetricCard compact label="Satisfaction" value="96%" />
</div>
)
}MetricTrend, such as "+12.5%". The arrow is decorative, and the color alone does not give the meaning.UsageBar has the meter role. Give it a label.infoLabel names the info button. The default is "More info".MetricCard and UsageBar also accept each attribute of the <div> element. MetricTrend and LegendDot accept each attribute of the <span> element.
| Prop | Type | Default |
|---|---|---|
labelRequired | ReactNode | - |
Short caption rendered as a | ||
value | ReactNode | - |
The number, already formatted ("12.4%", "$48,200", "1,284"; | ||
unit | ReactNode | - |
Small muted text right after the value ("of 10", "/ 50 GB", "per month"). | ||
trend | ReactNode | - |
Change indicator after the value, usually a | ||
hint | ReactNode | - |
Secondary line under the value ("Last 30 days", "Updated 2 min ago"). Truncated to one line. | ||
info | ReactNode | - |
Explanation shown in a tooltip from an (i) button next to the label (hover or keyboard focus). Keep it to one sentence; requires a | ||
infoLabel | string | More info |
Accessible name of the (i) info button. Defaults to "More info"; make it specific in dense grids. | ||
aside | ReactNode | - |
Right side of the header (12px muted text): a legend ( | ||
loading | boolean | false |
Shows a skeleton instead of the value, hides | ||
children | ReactNode | - |
Extra content under the value: a | ||
compact | boolean | false |
Tighter padding (16px instead of 20px) for dense grids of many small cards. | ||
| Prop | Type | Default |
|---|---|---|
direction | "flat" | "up" | "down" | up |
Arrow direction. Defaults to | ||
sentiment | "neutral" | "positive" | "negative" | - |
Color. Defaults to | ||
children | ReactNode | - |
The signed, formatted change ("+12.5%", "-3 pts"). It must make sense without the arrow. | ||
USAGE_BAR_TONES is the list of the tones.
| Prop | Type | Default |
|---|---|---|
valueRequired | number | null | - |
Percentage, 0–100 (clamped). | ||
tone | "auto" | "destructive" | "warning" | "brand" | auto |
Fill color. Defaults to | ||
label | string | - |
Accessible name of the meter ("Storage used"). Always set it, or pass | ||
warningAt | number | 75 |
Percentage at which the | ||
destructiveAt | number | 90 |
Percentage at which the | ||
LEGEND_DOT_TONES is the list of the tones.
| Prop | Type | Default |
|---|---|---|
tone | "destructive" | "warning" | "success" | "info" | "neutral" | "brand" | "chart-1" | "chart-2" | "chart-3" | "chart-4" | "chart-5" | brand |
Dot color. Defaults to | ||
dotClassName | string | - |
Extra classes for the dot itself (another | ||
children | ReactNode | - |
Series name, 1–2 words. Rendered UPPERCASE. | ||