Vorlage · React · HTML
Bento metrics
Ein Kennzahlenbereich in Tiles: große Zahlen, die einmal einrollen, wenn der Bereich sichtbar wird, mit Sparklines und Änderungs-Pills. Bei reduzierter Bewegung sofort fertig.
Vorschau
React
Installiere die Vorlage mit shadcn. Sie landet als normale Komponenten in deinem Projekt, die du selbst anpasst.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/bento-metrics.jsonnpx shadcn@latest add @manniche/bento-metricsbento-metrics.tsx
// Bento metrics: a heading with a one-line intro over a grid of figure tiles. Each tile is a label, the big figure
// (`BigNumber`), an optional change pill, a short note and, when it has a `trend`, a `Sparkline`. One tile can be
// inverted to lift the figure that matters most. `note` under the heading is where a section says what the numbers
// are, so a demo can say "Example data." in plain sight.
//
// Signature: the figures roll in when the section first comes into view. Until then (and on the server) the real
// values are in the page, so nothing depends on the animation; with reduced motion they simply stay put.
//
// Screen readers: one `h2`, a real list of tiles, each with an `h3` label. Every figure is read once, with its
// change ("up" or "down") and its trend sentence from the sparkline. The roll is hidden from them: they always
// hear the final value, never a half-counted one.
import { useCallback, useId, useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { BigNumber, DeltaPill } from '@/registry/manniche/chart-kit/chart-kit'
import { formatValue, type ValueFormat } from '@/registry/manniche/chart-kit/chart-utils'
import { DataTile, TileFact } from '@/registry/manniche/data-tile/data-tile'
import { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'
import { Sparkline } from '@/registry/manniche/sparkline/sparkline'
export type BentoMetric = {
/** A stable key for the tile. */
id: string
/** What the figure is, shown as the tile's `h3`: "Projects shipped". */
label: string
/** The figure. */
value: number
/** How the figure is written: currency, decimals, suffix. */
format?: ValueFormat
/** The change in the figure, shown as a pill (a percentage by default). Leave it out for none. */
delta?: number
/** How the change is written. Default one decimal and a percent sign. */
deltaFormat?: ValueFormat
/** Which direction of the change is good. "down" for things like refunds. Default "up". */
goodWhen?: 'up' | 'down'
/** Values for a small trend line, oldest first. Leave it out for none. */
trend?: readonly number[]
/** The trend's accessible name, such as "Projects shipped, last 8 months". Default the label. */
trendLabel?: string
/** A short line of context under the figure: what is counted and over which period. */
note?: ReactNode
/** Swap this tile to the opposite theme. Use it on one tile. */
inverted?: boolean
}
export type BentoMetricsProps = Omit<ComponentProps<'section'>, 'children' | 'title'> & {
/** The section's `h2`. */
heading: ReactNode
/** A short line under the heading. */
intro?: ReactNode
/** A small mono label above the heading. */
eyebrow?: ReactNode
/** A fine-print line under the grid, such as "Example data." or the source and date of real figures. */
note?: ReactNode
/** The figure tiles, two to six. */
metrics: readonly BentoMetric[]
/** Roll the figures in when the section first comes into view. Never under reduced motion. Default true. */
roll?: boolean
}
const COLS = {
2: '@4xl:grid-cols-2',
3: '@4xl:grid-cols-3',
4: '@4xl:grid-cols-2',
} as const
export function BentoMetrics({ heading, intro, eyebrow, note, metrics, roll = true, className, ref, ...rest }: BentoMetricsProps) {
const headingId = useId()
const reduced = useReducedMotion()
const root = useRef<HTMLElement | null>(null)
// The section needs its own handle for the observer, and a ref from the caller must still reach it.
const setRoot = useCallback(
(node: HTMLElement | null) => {
root.current = node
if (typeof ref === 'function') ref(node)
else if (ref) ref.current = node
},
[ref],
)
// "armed" shows the figures at zero until the section is seen; the real values are rendered until then.
const [armed, setArmed] = useState(false)
useLayoutEffect(() => {
const node = root.current
if (!roll || reduced || !node || typeof IntersectionObserver === 'undefined') return
setArmed(true)
const io = new IntersectionObserver(
([e]) => {
if (e.isIntersecting) {
setArmed(false)
io.disconnect()
}
},
// Any part of the section entering the lower 80 % of the view counts, so a section taller than the screen
// (one column at high zoom) still starts its roll.
{ threshold: 0, rootMargin: '0px 0px -20% 0px' },
)
io.observe(node)
return () => io.disconnect()
}, [roll, reduced])
// Derived rather than reset in the effect: if reduced motion turns on (or `roll` turns off) after the section
// was armed, the effect returns early and the figures must still show their real values.
const zero = armed && roll && !reduced
const cols = COLS[metrics.length as 2 | 3 | 4] ?? '@4xl:grid-cols-3'
return (
<section
ref={setRoot}
aria-labelledby={headingId}
className={cn('@container w-full py-12 @3xl:py-20', className)}
{...rest}
>
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<header className="mb-8 grid max-w-2xl gap-3 @3xl:mb-12">
{eyebrow && <p className="font-mono text-xs tracking-[0.04em] text-muted-foreground tabular-nums">{eyebrow}</p>}
<h2
id={headingId}
className="text-[clamp(30px,5.4cqw,52px)] leading-[1.02] font-extrabold tracking-[-0.035em] text-balance text-foreground"
style={{ fontFamily: 'var(--font-display, inherit)', fontStretch: '86%' }}
>
{heading}
</h2>
{intro && <p className="text-base leading-relaxed text-pretty text-muted-foreground">{intro}</p>}
</header>
<ul className={cn('grid list-none grid-cols-1 gap-3 p-0 @2xl:grid-cols-2 @2xl:gap-4', cols)}>
{metrics.map((m) => (
<li key={m.id} className="flex min-w-0">
<DataTile title={m.label} inverted={m.inverted} className="w-full">
<div className="grid h-full content-between gap-5">
<div className="grid gap-2">
<TileFact
aside={
m.delta !== undefined && (
<DeltaPill value={m.delta} format={m.deltaFormat} goodWhen={m.goodWhen} />
)
}
>
{/* The figure is spoken once, in full, whatever the roll is doing. */}
<span className="sr-only">{formatValue(m.value, m.format)}</span>
<span aria-hidden>
<BigNumber value={zero ? 0 : m.value} format={m.format} size="lg" roll={roll} />
</span>
</TileFact>
{m.note && <p className="text-[13.5px] leading-normal text-pretty text-muted-foreground">{m.note}</p>}
</div>
{m.trend && m.trend.length > 1 && (
<Sparkline data={m.trend} label={m.trendLabel ?? m.label} format={m.format} height={36} />
)}
</div>
</DataTile>
</li>
))}
</ul>
{note && <p className="mt-4 font-mono text-xs text-muted-foreground tabular-nums">{note}</p>}
</div>
</section>
)
}bento-metrics-demo.tsx
import { BentoMetrics, type BentoMetric } from '@/components/bento-metrics'
// Example figures for the demo, not real data.
const METRICS: BentoMetric[] = [
{
id: 'shipped',
label: 'Projects shipped',
value: 42,
delta: 12.5,
trend: [3, 4, 3, 5, 6, 5, 7, 9],
trendLabel: 'Projects shipped, last 8 months',
note: 'Counted when the client signs off, per month.',
inverted: true,
},
{
id: 'turnaround',
label: 'Days to first draft',
value: 4.5,
format: { decimals: 1 },
delta: -8.2,
goodWhen: 'down',
trend: [7, 6.5, 6, 5.5, 5.2, 5, 4.8, 4.5],
trendLabel: 'Days to first draft, last 8 months',
note: 'From signed brief to the first draft.',
},
{
id: 'revisions',
label: 'Revision rounds',
value: 1.8,
format: { decimals: 1 },
delta: 0,
note: 'Average per project. Fewer is better.',
},
{
id: 'hours',
label: 'Hours saved per week',
value: 126,
format: { suffix: ' h' },
delta: 6.4,
trend: [90, 96, 101, 99, 108, 115, 121, 126],
trendLabel: 'Hours saved per week, last 8 weeks',
note: 'Handover and review time the team no longer spends.',
},
{
id: 'budget',
label: 'Average project budget',
value: 18400,
format: { currency: 'EUR' },
delta: 3.1,
note: 'Fixed price, before VAT.',
},
{
id: 'ontime',
label: 'Delivered on time',
value: 94,
format: { suffix: '%' },
delta: 2,
deltaFormat: { decimals: 0, suffix: ' pts' },
trend: [88, 89, 91, 90, 92, 93, 93, 94],
trendLabel: 'Delivered on time, last 8 months',
note: 'Against the date in the brief.',
},
]
export default function BentoMetricsDemo() {
return (
<BentoMetrics
eyebrow="Halden Studio, Munich and Aalborg"
heading="A year of work, in six figures."
intro="What a small studio can count, and nothing it cannot."
note="Example data."
metrics={METRICS}
/>
)
}HTML
Die ganze Seite als eine Datei mit eingebautem CSS und ohne JavaScript. Umschalter, Tabs und Filter funktionieren allein mit HTML und CSS.
Die Farbe in deiner eigenen App
Wenn shadcn bereits eingerichtet ist, ergeben diese Variablen dieselbe Farbe. Hell in :root, dunkel in .dark.
:root {
--primary: oklch(0.52 0.2 262);
--primary-foreground: oklch(0.99 0 0);
--accent: oklch(0.95 0.034 262);
--accent-foreground: oklch(0.36 0.140 262);
--ring: oklch(0.52 0.2 262);
}
.dark {
--primary: oklch(0.74 0.150 262);
--primary-foreground: oklch(0.18 0.024 262);
--accent: oklch(0.29 0.050 262);
--accent-foreground: oklch(0.9 0.050 262);
--ring: oklch(0.74 0.150 262);
}Installieren
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/bento-metrics.json