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.jsonMit eingetragener Registry (components.json)
npx shadcn@latest add @manniche/chart-kit