Mikkel Manniche
Dansk English Deutsch

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.json
npx shadcn@latest add @manniche/bento-metrics
bento-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.

In neuem Tab öffnen

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

Abhängigkeiten

    Dateien

    • bento-metrics.tsx
    • bento-metrics.html

    Manniche Lab · Open Source

    Kostenlose Werkzeuge für KI-Agenten und Webentwicklung.