Mikkel Manniche
Dansk English Deutsch

Widgets

Chart kit

Die gemeinsamen Teile der Tiles-Diagramme: Zahlenformat mit gedämpfter Einheit, saubere Achsen, eine weiche Linie, ein Tooltip, die große Kennzahl, eine Änderungspille, ein Pillenschalter und eine Tabelle für Screenreader.

Quellcode

chart-kit.tsx
// The shared grammar for the Tiles data primitives (bar-chart, donut, dial, area-chart and the rest): a size and
// draw-in hook, one tooltip anatomy, the big display number, a change pill, a pill switch and a hidden data table
// for screen readers. Formatting, scales and colours live in chart-utils.ts.
// There is no chart library. Each primitive draws its own SVG with these pieces, so the axes, tooltips and numbers
// look the same everywhere. Series colours come from --chart-1 … --chart-5 and the signal from --primary.
import { AnimatePresence, motion } from 'motion/react'
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
import { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'
import { cn } from '@/lib/utils'
import { EASE, formatValue, stepIndex, valueParts, type ValueFormat } from './chart-utils'

// Tooltip -------------------------------------------------------------------------------------------------------

export type TooltipRow = { label: string; value: string; color?: string }

export type ChartTooltipProps = {
  open: boolean
  /** Where the tooltip points, in px inside the chart's positioned wrapper. */
  x: number
  y: number
  /** The wrapper's width, so the tooltip stays inside it. */
  bounds: number
  /** Above the point by default; below when the point sits near the top. */
  below?: boolean
  /** The small label on top, such as the date. */
  title: string
  /** The figure itself. */
  value: ReactNode
  rows?: TooltipRow[]
}

/**
 * One tooltip anatomy for every chart: a dark chip with a mono label, the figure and optional rows with a colour
 * key. It is hidden from screen readers; the chart says the same through its live region. Moves glide only while
 * it is already open, so it never flies in from a corner.
 */
export function ChartTooltip({ open, x, y, bounds, below = false, title, value, rows = [] }: ChartTooltipProps) {
  const box = useRef<HTMLDivElement>(null)
  const [size, setSize] = useState({ w: 0, h: 0 })
  const [gliding, setGliding] = useState(false)

  // Resize notes arrive after layout and before paint, so a new size is in place before the frame is drawn.
  useEffect(() => {
    const el = box.current
    if (!el) return
    const ro = new ResizeObserver(() => setSize({ w: el.offsetWidth, h: el.offsetHeight }))
    ro.observe(el)
    return () => ro.disconnect()
  }, [])
  // The first frame after opening snaps into place; moves after that glide.
  useEffect(() => {
    if (!open) return
    const id = requestAnimationFrame(() => setGliding(true))
    return () => {
      cancelAnimationFrame(id)
      setGliding(false)
    }
  }, [open])
  const { w, h } = size

  const tx = Math.max(-6, Math.min(bounds - w + 6, x - w / 2))
  const ty = below ? y + 12 : y - h - 12
  const origin = `${Math.max(10, Math.min(w - 10, x - tx))}px ${below ? '0%' : '100%'}`

  return (
    <div
      ref={box}
      aria-hidden
      className={cn(
        'pointer-events-none absolute top-0 left-0 z-30 w-max max-w-60 rounded-[calc(var(--radius)+2px)] bg-foreground px-3 pt-2 pb-2.5 text-card',
        'shadow-[0_1px_2px_rgba(0,0,0,0.14),0_10px_24px_-10px_rgba(0,0,0,0.4)] ease-out-quint motion-reduce:transition-none',
        gliding ? 'transition-[opacity,transform] duration-150' : 'transition-opacity duration-150',
      )}
      style={{
        opacity: open ? 1 : 0,
        transform: `translate(${tx}px, ${ty}px) scale(${open ? 1 : 0.96})`,
        transformOrigin: origin,
      }}
    >
      <span className="block font-mono text-[11px] leading-tight tracking-[0.02em] text-[color-mix(in_oklab,var(--card)_60%,var(--foreground))]">
        {title}
      </span>
      <span className="mt-1 block text-[17px] leading-[1.1] font-semibold tracking-[-0.015em] tabular-nums">{value}</span>
      {rows.map((r) => (
        <span
          key={r.label}
          className="mt-1.5 flex items-center gap-2 text-[12.5px] leading-[1.3] text-[color-mix(in_oklab,var(--card)_82%,var(--foreground))] tabular-nums"
        >
          {r.color && <i className="size-2.5 flex-none rounded-[3px]" style={{ background: r.color }} />}
          {r.label}
          <b className="ml-auto pl-3 font-medium text-card">{r.value}</b>
        </span>
      ))}
    </div>
  )
}

// The display number --------------------------------------------------------------------------------------------

export type BigNumberProps = {
  value: number
  format?: ValueFormat
  /** xl for the one figure a tile is about, sm for a figure inside a chart. Default "lg". */
  size?: 'xl' | 'lg' | 'md' | 'sm'
  /** Roll the digits that change. Default true; never under reduced motion. */
  roll?: boolean
  className?: string
}

const SIZES = {
  xl: 'text-[clamp(50px,5.2vw,76px)]',
  lg: 'text-[clamp(36px,3.4vw,48px)]',
  md: 'text-[30px]',
  sm: 'text-[22px]',
}

/**
 * The big figure: heavy, tight and tabular, with the currency symbol and the decimals set small and muted. It uses
 * --font-display when the app defines one (Archivo at width 86 % is what it was drawn with) and the body face
 * otherwise. Screen readers get the whole value once.
 */
export function BigNumber({ value, format, size = 'lg', roll = true, className }: BigNumberProps) {
  const p = valueParts(value, format)
  const small = 'text-[0.46em] font-bold tracking-[-0.02em] text-muted-foreground'
  const unit = p.unit && (
    <span className={cn(small, p.unitAfter ? 'ml-[0.12em]' : 'mr-[0.12em] self-start pt-[0.12em]')}>{p.unit}</span>
  )
  return (
    <span
      className={cn(
        'inline-flex items-baseline leading-[0.95] font-extrabold tracking-[-0.045em] whitespace-nowrap tabular-nums',
        SIZES[size],
        className,
      )}
      style={{ fontFamily: 'var(--font-display, inherit)', fontStretch: '86%' }}
    >
      <span className="sr-only">{formatValue(value, format)}</span>
      <span aria-hidden className="inline-flex items-baseline">
        {p.sign}
        {!p.unitAfter && unit}
        <Digits text={p.whole} roll={roll} />
        {p.fraction && (
          <span className={small}>
            <Digits text={p.fraction} roll={roll} />
          </span>
        )}
        {p.unitAfter && unit}
        {p.suffix && <span className={cn(small, 'ml-[0.06em]')}>{p.suffix}</span>}
      </span>
    </span>
  )
}

/** Digits that roll up when they grow and down when they shrink, counted from the right so places stay put. */
export function Digits({ text, roll = true }: { text: string; roll?: boolean }) {
  const reduced = useReducedMotion()
  const [last, setLast] = useState({ text, dir: 1 })
  const dir = last.text === text ? last.dir : Number(text.replace(/\D/g, '')) >= Number(last.text.replace(/\D/g, '')) ? 1 : -1
  if (last.text !== text) setLast({ text, dir })
  if (!roll || reduced) return <>{text}</>
  const chars = text.split('')
  return (
    <span className="inline-flex">
      {chars.map((ch, i) => {
        const place = chars.length - i
        return (
          <span key={place} className="relative -my-[0.08em] inline-flex overflow-hidden py-[0.08em]">
            <AnimatePresence mode="popLayout" initial={false} custom={dir}>
              <motion.span
                key={ch}
                initial={{ y: `${dir * 60}%`, opacity: 0, filter: 'blur(2px)' }}
                animate={{ y: 0, opacity: 1, filter: 'blur(0px)' }}
                exit={{ y: `${dir * -60}%`, opacity: 0, filter: 'blur(2px)' }}
                transition={{ duration: 0.3, ease: EASE, delay: Math.min(place, 6) * 0.015 }}
                className="inline-block"
              >
                {ch}
              </motion.span>
            </AnimatePresence>
          </span>
        )
      })}
    </span>
  )
}

/** A change as a small pill with an arrow. Green when it went the good way; set `goodWhen` to "down" for costs. The arrow carries the direction, so the figure never shows a sign. */
export function DeltaPill({
  value,
  format = { decimals: 1, suffix: '%' },
  goodWhen = 'up',
  className,
}: {
  value: number
  format?: ValueFormat
  goodWhen?: 'up' | 'down'
  className?: string
}) {
  const up = value > 0
  const flat = Number(Math.abs(value).toFixed(format.decimals ?? 0)) === 0
  const good = flat ? null : up === (goodWhen === 'up')
  const tone = good === null ? null : good ? 'var(--success)' : 'var(--destructive)'
  return (
    <span
      className={cn(
        'inline-flex h-[26px] items-center gap-1 rounded-full pr-2.5 pl-2 text-[12.5px] leading-none font-medium whitespace-nowrap tabular-nums',
        className,
      )}
      style={
        tone
          ? {
              background: `color-mix(in oklab, ${tone} ${good ? 15 : 13}%, var(--card))`,
              color: `color-mix(in oklab, ${tone} ${good ? 80 : 85}%, var(--foreground))`,
            }
          : { background: 'var(--muted)', color: 'var(--muted-foreground)' }
      }
    >
      {!flat && (
        <svg
          viewBox="0 0 12 12"
          className="size-3 fill-none stroke-current stroke-2 [stroke-linecap:round] [stroke-linejoin:round]"
          aria-hidden
        >
          <path d={up ? 'M6 10V2M2.5 5.5 6 2l3.5 3.5' : 'M6 2v8M2.5 6.5 6 10l3.5-3.5'} />
        </svg>
      )}
      {formatValue(Math.abs(value), { ...format, sign: false })}
      <span className="sr-only">{flat ? ', unchanged' : up ? ', up' : ', down'}</span>
    </span>
  )
}

// Controls ------------------------------------------------------------------------------------------------------

export type PillOption = { id: string; label: string }

/** A segment switch: a dark pill slides to the picked option. A radio group with arrow keys, Home and End. */
export function Pills({
  options,
  value,
  onChange,
  label,
  className,
}: {
  options: PillOption[]
  value: string
  onChange: (id: string) => void
  /** Read aloud for the group, e.g. "Period". */
  label: string
  className?: string
}) {
  const refs = useRef<(HTMLButtonElement | null)[]>([])
  const index = Math.max(0, options.findIndex((o) => o.id === value))
  const onKey = (e: KeyboardEvent, i: number) => {
    // Radio groups take both axes: down and right go forward, up and left go back.
    const key = e.key === 'ArrowDown' ? 'ArrowRight' : e.key === 'ArrowUp' ? 'ArrowLeft' : e.key
    const to = stepIndex(key, i, options.length, { loop: true })
    if (to === null) return
    e.preventDefault()
    refs.current[to]?.focus()
    onChange(options[to].id)
  }
  return (
    <div
      role="radiogroup"
      aria-label={label}
      className={cn('relative isolate inline-grid auto-cols-fr grid-flow-col rounded-full bg-muted p-1', className)}
    >
      <span
        aria-hidden
        className="absolute top-1 bottom-1 left-1 -z-10 rounded-full bg-foreground transition-transform duration-[260ms] ease-out-quint motion-reduce:transition-none"
        style={{ width: `calc((100% - 8px) / ${options.length})`, transform: `translateX(${index * 100}%)` }}
      />
      {options.map((o, i) => (
        <button
          key={o.id}
          ref={(el) => {
            refs.current[i] = el
          }}
          type="button"
          role="radio"
          aria-checked={i === index}
          tabIndex={i === index ? 0 : -1}
          onClick={() => onChange(o.id)}
          onKeyDown={(e) => onKey(e, i)}
          className={cn(
            'relative min-h-9 cursor-pointer rounded-full px-3.5 text-[13.5px] font-medium whitespace-nowrap focus-visible:outline-offset-0',
            // The visible pill is 36 px; the hit area reaches the 44 px of the track.
            'before:absolute before:inset-x-0 before:-inset-y-1 before:content-[""]',
            i === index ? 'text-card' : 'text-muted-foreground hover:text-foreground',
          )}
        >
          {o.label}
        </button>
      ))}
    </div>
  )
}

// Screen readers ------------------------------------------------------------------------------------------------

/** The chart's data as a table only screen readers see. The first column is read as the row heading. */
export function SrTable({ caption, head, rows }: { caption: string; head: string[]; rows: (string | number)[][] }) {
  return (
    <div className="sr-only">
      <table>
        <caption>{caption}</caption>
        <thead>
          <tr>
            {head.map((h) => (
              <th key={h} scope="col">
                {h}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {rows.map((r, i) => (
            <tr key={i}>
              {r.map((c, j) =>
                j ? (
                  <td key={j}>{c}</td>
                ) : (
                  <th key={j} scope="row">
                    {c}
                  </th>
                ),
              )}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}
chart-utils.ts
// The pure half of the chart kit: colours, number formatting, nice axis ticks and the smooth line. No React here,
// so it can also run on a server or in a test.

export const EASE = [0.23, 1, 0.32, 1] as const
export const EASE_CSS = 'cubic-bezier(0.23, 1, 0.32, 1)'
/** A real minus sign, which lines up with the plus in tabular figures. */
export const MINUS = '−'

/** The five series colours in their fixed order. */
export const SERIES = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)']
export const seriesColor = (i: number) => SERIES[((i % SERIES.length) + SERIES.length) % SERIES.length]

// Surface colours, mixed where they are used so they follow an inverted tile. One series is drawn in INK; the signal
// (today, the latest point, the target) is the only thing in --primary.
/** The main mark: bars, the line, the needle. */
export const INK = 'color-mix(in oklab, var(--foreground) 86%, var(--card))'
/** The comparison period, behind the main mark. */
export const INK_GHOST = 'color-mix(in oklab, var(--foreground) 17%, var(--card))'
/** Dashed gridlines. */
export const GRID = 'color-mix(in oklab, var(--foreground) 11%, var(--card))'
/** The baseline and dashed comparison lines. */
export const BASE = 'color-mix(in oklab, var(--foreground) 30%, var(--card))'
/** A recessed well, such as an empty track. */
export const WELL = 'color-mix(in oklab, var(--foreground) 5%, var(--card))'

// Numbers -------------------------------------------------------------------------------------------------------

export type ValueFormat = {
  /** Passed to Intl.NumberFormat. Default "en-GB". */
  locale?: string
  /** ISO code such as "EUR" or "DKK". Leave it out for a plain number. */
  currency?: string
  /** Digits after the decimal mark. Default 0. */
  decimals?: number
  /** Show a plus on positive values, as for changes. */
  sign?: boolean
  /** A unit set after the number, such as "%" or " orders". */
  suffix?: string
}

export type ValueParts = {
  sign: '' | '+' | typeof MINUS
  /** The currency symbol, or an empty string. */
  unit: string
  /** True when the locale puts the symbol after the number, as "1.234 kr." does. */
  unitAfter: boolean
  /** The whole part with its group separators. */
  whole: string
  /** The decimal mark and the decimals, or an empty string. */
  fraction: string
  suffix: string
}

const formatters = new Map<string, Intl.NumberFormat>()
function formatter(locale: string, options: Intl.NumberFormatOptions) {
  const key = locale + JSON.stringify(options)
  let f = formatters.get(key)
  if (!f) {
    f = new Intl.NumberFormat(locale, options)
    formatters.set(key, f)
  }
  return f
}

/** Splits a value into the pieces the display number sets in different sizes and colours. */
export function valueParts(value: number, format: ValueFormat = {}): ValueParts {
  const { locale = 'en-GB', currency, decimals = 0, sign = false, suffix = '' } = format
  const rounded = Number(Math.abs(value).toFixed(decimals))
  const parts = formatter(locale, {
    style: currency ? 'currency' : 'decimal',
    currency,
    currencyDisplay: 'narrowSymbol',
    minimumFractionDigits: decimals,
    maximumFractionDigits: decimals,
  }).formatToParts(rounded)
  let unit = ''
  let whole = ''
  let fraction = ''
  let seenNumber = false
  let unitAfter = false
  for (const p of parts) {
    if (p.type === 'currency') {
      unit = p.value
      unitAfter = seenNumber
    } else if (p.type === 'integer' || p.type === 'group') {
      whole += p.value
      seenNumber = true
    } else if (p.type === 'decimal' || p.type === 'fraction') fraction += p.value
  }
  const s = rounded === 0 ? '' : value < 0 ? MINUS : sign ? '+' : ''
  return { sign: s, unit, unitAfter, whole, fraction, suffix }
}

/** The value as one string, for tooltips, tables and screen readers. */
export function formatValue(value: number, format: ValueFormat = {}) {
  const p = valueParts(value, format)
  const space = p.unitAfter ? ' ' : ''
  return p.unitAfter
    ? `${p.sign}${p.whole}${p.fraction}${space}${p.unit}${p.suffix}`
    : `${p.sign}${p.unit}${p.whole}${p.fraction}${p.suffix}`
}

/** A short value for axis ticks: "€1.2k", "40". */
export function formatTick(value: number, format: ValueFormat = {}) {
  const { locale = 'en-GB', currency } = format
  return formatter(locale, {
    style: currency ? 'currency' : 'decimal',
    currency,
    currencyDisplay: 'narrowSymbol',
    notation: Math.abs(value) >= 10000 ? 'compact' : 'standard',
    maximumFractionDigits: Math.abs(value) >= 10000 ? 1 : 0,
  }).format(value)
}

// Scales --------------------------------------------------------------------------------------------------------

/** Round axis bounds and ticks that cover min…max in about `count` steps of 1, 2, 2.5 or 5 × 10ⁿ. */
export function niceScale(max: number, count = 4, min = 0) {
  const span = Math.max(max - min, Number.EPSILON)
  const raw = span / Math.max(1, count)
  const mag = 10 ** Math.floor(Math.log10(raw))
  const step = ([1, 2, 2.5, 5, 10].find((m) => m * mag >= raw) ?? 10) * mag
  const lo = Math.floor(min / step) * step
  const hi = Math.ceil(max / step) * step
  const ticks: number[] = []
  for (let v = lo; v <= hi + step / 2; v += step) ticks.push(Number(v.toFixed(10)))
  return { min: lo, max: hi, step, ticks }
}

/** A linear map from a domain to a range. */
export function linear(d0: number, d1: number, r0: number, r1: number) {
  const k = d1 === d0 ? 0 : (r1 - r0) / (d1 - d0)
  return (v: number) => r0 + (v - d0) * k
}

/** A smooth line through points that never overshoots them (monotone cubic, Fritsch–Carlson). */
export function smoothPath(pts: [number, number][]) {
  const n = pts.length
  if (n === 0) return ''
  if (n < 3) return pts.map(([x, y], i) => `${i ? 'L' : 'M'}${r2(x)} ${r2(y)}`).join('')
  const dx: number[] = []
  const m: number[] = []
  for (let i = 0; i < n - 1; i++) {
    dx.push(pts[i + 1][0] - pts[i][0])
    m.push((pts[i + 1][1] - pts[i][1]) / (dx[i] || 1))
  }
  const t = [m[0], ...m.slice(1).map((s, i) => (s * m[i] <= 0 ? 0 : (s + m[i]) / 2)), m[n - 2]]
  for (let i = 0; i < n - 1; i++) {
    if (m[i] === 0) {
      t[i] = 0
      t[i + 1] = 0
      continue
    }
    const a = t[i] / m[i]
    const b = t[i + 1] / m[i]
    const h = a * a + b * b
    if (h > 9) {
      const s = 3 / Math.sqrt(h)
      t[i] = s * a * m[i]
      t[i + 1] = s * b * m[i]
    }
  }
  let d = `M${r2(pts[0][0])} ${r2(pts[0][1])}`
  for (let i = 0; i < n - 1; i++) {
    const [x0, y0] = pts[i]
    const [x1, y1] = pts[i + 1]
    const h = dx[i] / 3
    d += `C${r2(x0 + h)} ${r2(y0 + t[i] * h)} ${r2(x1 - h)} ${r2(y1 - t[i + 1] * h)} ${r2(x1)} ${r2(y1)}`
  }
  return d
}

const r2 = (v: number) => Math.round(v * 100) / 100

/** The next index for an arrow, Home or End key, or null for any other key. */
export function stepIndex(key: string, index: number, count: number, { loop = false, vertical = false } = {}) {
  const next = vertical ? 'ArrowDown' : 'ArrowRight'
  const prev = vertical ? 'ArrowUp' : 'ArrowLeft'
  if (key === 'Home') return 0
  if (key === 'End') return count - 1
  if (key === next) return loop ? (index + 1) % count : Math.min(count - 1, index + 1)
  if (key === prev) return loop ? (index - 1 + count) % count : Math.max(0, index - 1)
  return null
}
use-chart.tsx
// The chart hooks: size and draw-in, a polite live region, and unique ids for SVG defs.
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'

/**
 * Watches an element's size and whether it has come into view. `drawn` turns true once, the first time a fifth of
 * the chart is visible, and at once under reduced motion. The ref is a callback, so a chart that mounts its frame
 * later (after loading, or when data arrives) is still measured.
 */
export function useChartFrame<T extends Element = HTMLDivElement>() {
  const [node, setNode] = useState<T | null>(null)
  const [size, setSize] = useState({ width: 0, height: 0 })
  const [seen, setSeen] = useState(false)
  const reduced = useReducedMotion()

  useEffect(() => {
    if (!node) return
    const ro = new ResizeObserver(([e]) => {
      const width = Math.round(e.contentRect.width)
      const height = Math.round(e.contentRect.height)
      setSize((s) => (s.width === width && s.height === height ? s : { width, height }))
    })
    ro.observe(node)
    return () => ro.disconnect()
  }, [node])

  useEffect(() => {
    if (!node || seen) return
    const io = new IntersectionObserver(
      ([e]) => {
        if (e.isIntersecting) setSeen(true)
      },
      { threshold: 0.2 },
    )
    io.observe(node)
    return () => io.disconnect()
  }, [node, seen])

  return { ref: setNode, width: size.width, height: size.height, drawn: seen || reduced, reduced }
}

/** A polite live region and a function that reads a sentence into it. Render `region` once inside the chart. */
export function useAnnounce() {
  const [text, setText] = useState('')
  const timer = useRef(0)
  const say = useCallback((t: string) => {
    // Clear first, so the same sentence twice is read twice.
    setText('')
    window.clearTimeout(timer.current)
    timer.current = window.setTimeout(() => setText(t), 30)
  }, [])
  useEffect(() => () => window.clearTimeout(timer.current), [])
  const region = (
    <span className="sr-only" aria-live="polite" aria-atomic="true">
      {text}
    </span>
  )
  return { say, region }
}

/** A stable id prefix for SVG defs (patterns, clip paths) that stays unique per chart. */
export function useSvgId(name: string) {
  return `${name}-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`
}
use-reduced-motion.ts
import { useSyncExternalStore } from 'react'

const QUERY = '(prefers-reduced-motion: reduce)'

function subscribe(onChange: () => void) {
  const mq = window.matchMedia(QUERY)
  mq.addEventListener('change', onChange)
  return () => mq.removeEventListener('change', onChange)
}

/** True when the visitor has asked the system for less motion. */
export function useReducedMotion() {
  return useSyncExternalStore(
    subscribe,
    () => window.matchMedia(QUERY).matches,
    () => false,
  )
}

Installieren

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/chart-kit.json

Mit eingetragener Registry (components.json)

npx shadcn@latest add @manniche/chart-kit

Abhängigkeiten

Dateien

  • chart-kit.tsx
  • chart-utils.ts
  • use-chart.tsx
  • use-reduced-motion.ts

Manniche Lab · Open Source

Kostenlose Werkzeuge für KI-Agenten und Webentwicklung.