Bento metrics
templatesGratisMITEn sektion med nøgletal i Tiles: store tal, der ruller ind én gang, når sektionen kommer i syne, med sparklines og ændringspiller. Står stille med reduceret bevægelse.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/bento-metrics.jsonPreview
Den rene HTML-fil. Farve og tema skifter i rammen.
Kode
React
Installér skabelonen med shadcn. Den lægges i dit projekt som almindelige komponenter, som du selv retter i.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/bento-metrics.jsonnpx shadcn@latest add @manniche/bento-metrics// 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>
)
}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
Hele siden som én fil med al CSS indbygget og intet JavaScript. Knapper, faner og filtre virker med HTML og CSS alene.
Farven i din egen app
Har du allerede shadcn sat op, giver disse variabler samme farve. Lys i :root, mørk i .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);
}Props
BentoMetricsProps
| Prop | Type | Standard | Hvad den gør |
|---|---|---|---|
heading påkrævet | ReactNode | – | The section's h2. |
intro | ReactNode | – | A short line under the heading. |
eyebrow | ReactNode | – | A small mono label above the heading. |
note | ReactNode | – | A fine-print line under the grid, such as "Example data." or the source and date of real figures. |
metrics påkrævet | readonly BentoMetric[] | – | The figure tiles, two to six. |
roll | boolean | true | Roll the figures in when the section first comes into view. Never under reduced motion. Default true. |
Tager også alle props fra Omit<ComponentProps<'section'>, 'children' | 'title'>.
A11y
Skærmlæser
A section named by its h2 with a list of tiles with an h3 label each. The real value is in screen-reader-only text and the rolling digits are aria-hidden; DeltaPill from chart-kit reads the direction; the sparkline takes the metric label (or trendLabel) as its label.
Mindre bevægelse
With roll (default true) the figures show 0 after mount and roll up once when they scroll into view; the server markup has the real values. Under reduced motion they never roll and show the real values.
Detaljer
Afhængigheder
Ingen ud over React og Tailwind.