Widgets
Sparkline
Eine kleine Trendlinie ohne Achsen neben einer Zahl oder in einer Tabellenzelle, mit einem Punkt auf dem letzten Wert, einem Tooltip und einem 44-px-Ziel für Tastatur und Touch.
Vorschau
Verwendung
sparkline-demo.tsx
import { useState } from 'react'
import { BigNumber, DeltaPill, Pills } from '@/components/chart-kit'
import { valueParts } from '@/components/chart-utils'
import { DataTile, TileFact } from '@/components/data-tile'
import { Sparkline, type SparkPoint } from '@/components/sparkline'
// Example data for the demo, not real accounts. The last 30 days end on Thursday 8 October.
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const DATES = Array.from({ length: 30 }, (_, i) => {
const d = i < 22 ? { date: 9 + i, mon: 'Sep' } : { date: i - 21, mon: 'Oct' }
return `${DAYS[(2 + i) % 7]} ${d.date} ${d.mon}`
})
const SAVINGS = [
4752.22, 4760.73, 4778.07, 4787.34, 4811.17, 4829.03, 4847.02, 4858.13, 4871.38, 4890.76, 4902.57, 4912.03, 4934.81, 4952.59,
4977.06, 5003.36, 5017.89, 5040.18, 5053.94, 5075.84, 5094.66, 5119.94, 5331.3, 5339.23, 5365.48, 5379.73, 5407.23, 5431.19,
5447.78, 5470.58,
]
const EVERYDAY = [
779.01, 744.95, 672.86, 593.06, 541.94, 505, 473.88, 441.66, 396, 328.94, 264.49, 215.86, 186.75, 158.34, 122.33, 103.15, 31.8,
-39.4, -123.09, -149.3, -186.81, 2239.41, 2222.86, 2136.52, 2086.64, 2015.4, 2180.47, 2144.68, 2110.95, 2091.2,
]
const TRAVEL = [
734.46, 709.81, 709.81, 709.81, 709.81, 709.81, 690.84, 690.84, 690.84, 690.84, 690.84, 690.84, 690.84, 690.84, 690.84, 690.84,
690.84, 690.84, 690.84, 690.84, 670.53, 670.53, 644.24, 628.45, 628.45, 603.42, 603.42, 601.37, 601.37, 612.08,
]
const ONLINE = [
117.84, 157.84, 197.84, 237.84, 277.84, 317.84, 314.49, 310.56, 308.81, 348.81, 346.62, 342.92, 382.92, 380.32, 378.28, 418.28,
415.86, 455.86, 455.62, 495.62, 490.13, 485.78, 483.59, 479.38, 519.38, 518.88, 558.88, 558.17, 598.17, 145.2,
]
const TOTAL = EVERYDAY.map((v, i) => Math.round((v + SAVINGS[i] + TRAVEL[i] + ONLINE[i]) * 100) / 100)
const dated = (values: number[]): SparkPoint[] => values.map((value, i) => ({ value, label: DATES[i] }))
const EUR = { currency: 'EUR', decimals: 2 }
const change = (xs: number[]) => ((xs[xs.length - 1] - xs[0]) / Math.abs(xs[0])) * 100
const ACCOUNTS = [
{ name: 'Everyday', sub: 'Debit •••• 4821', values: EVERYDAY },
{ name: 'Travel', sub: 'Multi-currency', values: TRAVEL },
{ name: 'Online', sub: 'Virtual', values: ONLINE },
]
const RANGES = [
{ id: 'month', label: '30 days' },
{ id: 'week', label: '7 days' },
]
/** A balance for the table: the euro sign muted, as on the big numbers. */
function Money({ value }: { value: number }) {
const p = valueParts(value, EUR)
return (
<>
{p.sign}
<span className="text-muted-foreground">{p.unit}</span>
{p.whole}
{p.fraction}
</>
)
}
export default function SparklineDemo() {
const [range, setRange] = useState('month')
const total = range === 'week' ? TOTAL.slice(-7) : TOTAL
const totalPoints = dated(TOTAL).slice(-total.length)
return (
<div className="@container grid w-full gap-4">
<DataTile title="Accounts" footer={<span>A 30-day trend beside each balance. Example data.</span>}>
<TileFact label="Savings pot">
<div className="flex w-full flex-wrap items-end justify-between gap-4">
<BigNumber value={SAVINGS[SAVINGS.length - 1]} size="lg" format={EUR} />
<Sparkline data={dated(SAVINGS)} label="Savings pot, last 30 days" format={EUR} width={132} height={44} />
</div>
</TileFact>
<table className="mt-[22px] w-full border-collapse text-sm tabular-nums">
<caption className="sr-only">Accounts with a 30-day trend</caption>
<thead>
<tr>
<th scope="col" className="pb-2 text-left font-mono text-[11px] leading-none font-normal text-muted-foreground">
Account
</th>
<th scope="col" className="px-3.5 pb-2 text-left font-mono text-[11px] leading-none font-normal text-muted-foreground">
30 days
</th>
<th scope="col" className="pb-2 text-right font-mono text-[11px] leading-none font-normal text-muted-foreground">
Balance
</th>
</tr>
</thead>
<tbody>
{ACCOUNTS.map((a, i) => (
<tr key={a.name}>
<th scope="row" className="border-t border-border py-[9px] text-left align-middle font-normal">
<span className="block font-medium">{a.name}</span>
<span className="block font-mono text-[11px] leading-[1.3] text-muted-foreground">{a.sub}</span>
</th>
<td className="w-[124px] border-t border-border px-3.5 align-middle">
<Sparkline
data={dated(a.values)}
label={`${a.name}, last 30 days`}
format={EUR}
height={28}
table={false}
delay={120 + i * 80}
/>
</td>
<td className="border-t border-border py-[9px] text-right align-middle">
<Money value={a.values[a.values.length - 1]} />
</td>
</tr>
))}
</tbody>
</table>
</DataTile>
<div className="grid gap-4 @min-[520px]:grid-cols-2">
<DataTile
title="Total balance"
density="compact"
inverted
action={<Pills label="Range" options={RANGES} value={range} onChange={setRange} />}
footer={<span>All four accounts. Example data.</span>}
>
<TileFact aside={<DeltaPill value={change(total)} format={{ decimals: 1, sign: true, suffix: '%' }} />}>
<BigNumber value={total[total.length - 1]} size="md" format={EUR} />
</TileFact>
<Sparkline
className="mt-3.5"
data={totalPoints}
label={`Total balance, last ${range === 'week' ? 7 : 30} days`}
format={EUR}
height={56}
/>
</DataTile>
<DataTile title="Other states" density="compact" footer={<span>Example data.</span>}>
<ul className="grid gap-3 text-sm">
<li className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">No marker, one scale</span>
<span className="flex gap-3">
<Sparkline data={TRAVEL} label="Travel, last 30 days" format={EUR} width={64} current={null} domain={[0, 800]} table={false} />
<Sparkline data={ONLINE} label="Online, last 30 days" format={EUR} width={64} current={null} domain={[0, 800]} table={false} />
</span>
</li>
<li className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Loading</span>
<Sparkline data={[]} label="Savings, last 30 days" loading width={132} />
</li>
<li className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">No data yet</span>
<Sparkline data={[]} label="New pot, last 30 days" width={132} />
</li>
</ul>
</DataTile>
</div>
</div>
)
}Quellcode
sparkline.tsx
// Sparkline: a tiny trend line with no axes, meant to sit beside a number or inside a table cell. The line is drawn
// in ink and the latest point (or any `current` index) is marked with a small dot in --primary, the only signal.
//
// How it works: the values are scaled into the measured box (its own min to max, or a shared `domain` so several
// sparklines can be compared) and drawn as one smooth line that never overshoots its points. It fills its
// container's width unless `width` is given; the height is 30 px (24 in a compact tile) unless `height` is given.
// Pointer hover and a finger scrubbing sideways pick the nearest point and show the shared tooltip; the page still
// scrolls under a finger. The plot is one Tab stop, with a hit area of at least 44 px however thin the line is.
// Arrow keys, Home and End step through the points and Escape lets go.
//
// Screen readers: the sparkline is one image named "<label>, up 11.2 percent, from €4,752.22 to €5,470.58", with a
// hint; each step by keyboard is read once ("Thu 8 Oct, latest: €5,470.58"), and the points are in a hidden table
// unless `table` is off (as inside a table row, where the row already says what the line is about).
//
// Motion: on first view the line is wiped in from the left by scaling its clip shape (300 ms, after an optional
// `delay` of at most 300 ms, so all is done within 600 ms). New data redraws at once. Under reduced motion the line
// is there at once.
import {
useCallback,
useEffect,
useId,
useState,
type ComponentProps,
type KeyboardEvent,
type PointerEvent,
} from 'react'
import { cn } from '@/lib/utils'
import { ChartTooltip, SrTable } from '@/registry/manniche/chart-kit/chart-kit'
import { EASE_CSS, GRID, INK, formatValue, smoothPath, stepIndex, valueParts, type ValueFormat } from '@/registry/manniche/chart-kit/chart-utils'
import { useAnnounce, useChartFrame, useSvgId } from '@/registry/manniche/chart-kit/use-chart'
export type SparkPoint = {
/** The point's value. */
value: number
/** The point's name for the tooltip, the table and screen readers: "Thu 8 Oct". Default "3 of 30". */
label?: string
}
export type SparklineSummary = {
/** The first and last values, already formatted. */
first: string
last: string
/** The change from the first to the last value in percent, or null when the first value is 0. */
change: number | null
}
export type SparklineLabels = {
/** Added to the current point's name in the tooltip and when read: "Thu 8 Oct · latest". Default "latest". */
current?: string
/** The first column of the hidden table. Default "Point". */
point?: string
/** The second column of the hidden table. Default "Value". */
value?: string
/** Names a point that has no label. Default "3 of 30". */
position?: (index: number, count: number) => string
/** The trend read after the chart's name. Default "up 11.2 percent, from €4,752.22 to €5,470.58". */
summary?: (s: SparklineSummary) => string
/** Read after the name. Default "Use the arrow keys to read each point." */
hint?: string
/** Read while loading. Default "Loading". */
loading?: string
/** Read when there is nothing to draw. Default "No data yet". */
empty?: string
}
const LABELS: Required<SparklineLabels> = {
current: 'latest',
point: 'Point',
value: 'Value',
position: (i, n) => `${i + 1} of ${n}`,
summary: ({ first, last, change }) => {
const trend =
change === null ? '' : Math.abs(change) < 0.05 ? 'unchanged, ' : `${change > 0 ? 'up' : 'down'} ${Math.abs(change).toFixed(1)} percent, `
return `${trend}from ${first} to ${last}`
},
hint: 'Use the arrow keys to read each point.',
loading: 'Loading',
empty: 'No data yet',
}
export type SparklineProps = Omit<ComponentProps<'div'>, 'children'> & {
/** The values, oldest first: plain numbers or `{ value, label? }`. */
data: readonly (number | SparkPoint)[]
/** The chart's accessible name, such as "Savings, last 30 days". The trend is read after it. */
label: string
/** How values are written in the tooltip, the summary and the table. */
format?: ValueFormat
/** A fixed width in px. Default the container's full width. */
width?: number
/** The height in px. Default 30, and 24 in a compact tile. */
height?: number
/** The index of the point marked with the signal dot. Default the last point; null for none. */
current?: number | null
/** The values at the bottom and top of the box, to put several sparklines on one scale. Default each line's own min and max. */
domain?: [number, number]
/** Show a tooltip and read points by hover, touch and keyboard. Off draws a still image. Default true. */
interactive?: boolean
/** Put the points in a hidden table for screen readers. Turn it off inside a table row. Default true. */
table?: boolean
/** Wait this long (ms, at most 300) before the first-view wipe, to stagger several lines. Default 0. */
delay?: number
/** Show a breathing placeholder line instead of data. */
loading?: boolean
/** The highlighted point (an index into `data`), or null. */
activeIndex?: number | null
/** The highlighted point to start with, when `activeIndex` is not controlled. Default null. */
defaultActiveIndex?: number | null
/** Called when hover, a finger or the keyboard picks another point, and with null when it lets go. */
onActiveIndexChange?: (index: number | null) => void
/** Every visible and spoken string that is not data. */
labels?: SparklineLabels
}
// Room around the line: the stroke's round caps at the left, the signal dot at the right, and a little at the top
// and bottom so the dot and the hover ring are never cut.
const PAD_X = 2
const PAD_DOT = 6
const PAD_Y = 4
// A gentle placeholder shape, in 0…1 of the box.
const SKELETON = [0.62, 0.5, 0.56, 0.38, 0.44, 0.3, 0.36]
function useControllable(controlled: number | null | undefined, initial: number | null, onChange?: (v: number | null) => void) {
const [inner, setInner] = useState(initial)
const isControlled = controlled !== undefined
const value = isControlled ? controlled : inner
const set = useCallback(
(v: number | null) => {
if (v === value) return
if (!isControlled) setInner(v)
onChange?.(v)
},
[value, isControlled, onChange],
)
return [value, set] as const
}
/** A tiny trend line with the latest point marked, for beside a number or inside a table cell. */
export function Sparkline({
data,
label,
format,
width: widthProp,
height,
current,
domain,
interactive = true,
table = true,
delay = 0,
loading = false,
activeIndex,
defaultActiveIndex = null,
onActiveIndexChange,
labels: labelsProp,
className,
style,
...rest
}: SparklineProps) {
const labels = { ...LABELS, ...labelsProp }
const { ref: frameRef, ...frame } = useChartFrame<HTMLDivElement>()
const { say, region } = useAnnounce()
const hintId = useId()
const clipId = useSvgId('spark')
const [rawActive, setActive] = useControllable(activeIndex, defaultActiveIndex, onActiveIndexChange)
const points = data.map((d, i) => {
const p = typeof d === 'number' ? { value: d } : d
return { value: p.value, title: p.label ?? labels.position(i, data.length) }
})
const n = points.length
const nowIndex = current === undefined ? n - 1 : current !== null && current >= 0 && current < n ? current : -1
const at = rawActive !== null && rawActive >= 0 && rawActive < n ? rawActive : -1
const empty = !loading && n === 0
const live = interactive && !loading && !empty
const W = frame.width
const H = frame.height
const x0 = PAD_X
const x1 = Math.max(x0, W - (nowIndex >= 0 ? PAD_DOT : PAD_X))
const values = points.map((p) => p.value)
const lo = domain ? domain[0] : Math.min(...values)
const hi = domain ? domain[1] : Math.max(...values)
const xOf = (i: number) => (n < 2 ? (x0 + x1) / 2 : x0 + (i / (n - 1)) * (x1 - x0))
const yOf = (v: number) => (hi === lo ? H / 2 : PAD_Y + (1 - (v - lo) / (hi - lo)) * Math.max(0, H - PAD_Y * 2))
const pts = points.map((p, i) => [xOf(i), yOf(p.value)] as [number, number])
// The first view wipes the line in once; later data just redraws.
const ready = frame.drawn && W > 0 && H > 0
const [played, setPlayed] = useState(false)
const shown = frame.reduced ? ready : played
useEffect(() => {
if (!ready || played || frame.reduced) return
let b = 0
const a = requestAnimationFrame(() => {
b = requestAnimationFrame(() => setPlayed(true))
})
return () => {
cancelAnimationFrame(a)
cancelAnimationFrame(b)
}
}, [ready, played, frame.reduced])
// A point that no longer exists lets go.
useEffect(() => {
if (rawActive !== null && (rawActive < 0 || rawActive >= n)) setActive(null)
}, [rawActive, n, setActive])
const name = (i: number) => (i === nowIndex ? `${points[i].title}, ${labels.current}` : points[i].title)
const describe = (i: number) => `${name(i)}: ${formatValue(points[i].value, format)}`
const pick = (i: number, announce: boolean) => {
setActive(i)
if (announce) say(describe(i))
}
const fromPointer = (e: PointerEvent<HTMLDivElement>) => {
if (!live || n < 1) return
const r = e.currentTarget.getBoundingClientRect()
const t = n < 2 ? 0 : (e.clientX - r.left - x0) / Math.max(1, x1 - x0)
const i = Math.max(0, Math.min(n - 1, Math.round(t * (n - 1))))
if (i !== at) pick(i, false)
}
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (!live) return
if (e.key === 'Escape') {
if (at < 0) return
e.preventDefault()
setActive(null)
return
}
const to = stepIndex(e.key, at < 0 ? (nowIndex >= 0 ? nowIndex : n - 1) : at, n)
if (to === null) return
e.preventDefault()
pick(to, true)
}
const first = n ? points[0].value : 0
const last = n ? points[n - 1].value : 0
const summary = n
? labels.summary({
first: formatValue(first, format),
last: formatValue(last, format),
change: first === 0 || n < 2 ? null : ((last - first) / Math.abs(first)) * 100,
})
: ''
const name0 = loading ? `${label}, ${labels.loading}` : empty ? `${label}, ${labels.empty}` : `${label}, ${summary}`
const wipe = Math.max(0, Math.min(300, delay))
const tip = live && at >= 0 && W > 0 ? points[at] : null
const dotNow = nowIndex >= 0 ? pts[nowIndex] : null
return (
<div
className={cn('relative min-w-0', widthProp === undefined ? 'block w-full' : 'inline-block align-middle', className)}
style={{ width: widthProp, ...style }}
aria-busy={loading || undefined}
{...rest}
>
<div
ref={frameRef}
role="img"
aria-label={name0}
aria-describedby={live ? hintId : undefined}
tabIndex={live ? 0 : undefined}
onPointerDown={live ? fromPointer : undefined}
onPointerMove={live ? fromPointer : undefined}
onPointerLeave={
live
? (e) => {
if (e.pointerType !== 'mouse') return
if (!e.currentTarget.matches(':focus-visible')) setActive(null)
}
: undefined
}
onFocus={
live
? (e) => {
// A press focuses the line too; only keyboard focus picks the current point.
if (!e.currentTarget.matches(':focus-visible')) return
if (at >= 0) {
say(describe(at))
return
}
pick(nowIndex >= 0 ? nowIndex : n - 1, true)
}
: undefined
}
onBlur={live ? () => setActive(null) : undefined}
onKeyDown={live ? onKeyDown : undefined}
className={cn(
'relative h-[30px] rounded-lg outline-offset-4 select-none group-data-[density=compact]/tile:h-6',
live &&
// However thin the line, the hit area is at least 44 px tall.
'cursor-crosshair [touch-action:pan-y] before:absolute before:inset-x-0 before:top-[min(0px,calc(50%-22px))] before:bottom-[min(0px,calc(50%-22px))] before:content-[""]',
)}
style={{ height }}
>
{W > 0 && H > 0 && (
<svg aria-hidden width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="pointer-events-none absolute inset-0 block overflow-visible">
{loading ? (
<path
d={smoothPath(SKELETON.map((v, i) => [x0 + (i / (SKELETON.length - 1)) * (W - 2 * PAD_X), PAD_Y + v * (H - 2 * PAD_Y)]))}
fill="none"
stroke="var(--muted)"
strokeWidth={3}
strokeLinecap="round"
className="motion-safe:animate-pulse"
/>
) : empty ? (
<line x1={x0} x2={W - PAD_X} y1={H / 2} y2={H / 2} stroke={GRID} strokeWidth={1.5} strokeDasharray="2 4" strokeLinecap="round" />
) : (
<>
<defs>
<clipPath id={clipId}>
<rect
x={0}
y={-PAD_Y * 2}
width={W + PAD_X}
height={H + PAD_Y * 4}
style={{
transformBox: 'view-box',
transformOrigin: '0px 0px',
transform: `scaleX(${shown ? 1 : 0})`,
transition:
shown && !frame.reduced ? `transform 300ms ${EASE_CSS} ${wipe}ms` : 'none',
}}
/>
</clipPath>
</defs>
<g clipPath={`url(#${clipId})`}>
{n > 1 && (
<path d={smoothPath(pts)} fill="none" stroke={INK} strokeWidth={1.75} strokeLinejoin="round" strokeLinecap="round" />
)}
{n === 1 && <circle cx={pts[0][0]} cy={pts[0][1]} r={2.5} fill={INK} />}
{dotNow && <circle cx={dotNow[0]} cy={dotNow[1]} r={3.5} fill="var(--primary)" stroke="var(--card)" strokeWidth={2.5} />}
</g>
{tip && (
<g>
<line
x1={pts[at][0]}
x2={pts[at][0]}
y1={Math.min(0, pts[at][1] - 8)}
y2={Math.max(H, pts[at][1] + 8)}
stroke="var(--foreground)"
strokeOpacity={0.35}
strokeWidth={1}
shapeRendering="crispEdges"
/>
{at === nowIndex ? (
<circle cx={pts[at][0]} cy={pts[at][1]} r={3.5} fill="var(--primary)" stroke="var(--card)" strokeWidth={2.5} />
) : (
<circle cx={pts[at][0]} cy={pts[at][1]} r={3.5} fill="var(--foreground)" stroke="var(--card)" strokeWidth={2.5} />
)}
</g>
)}
</>
)}
</svg>
)}
</div>
{live && (
<>
<span id={hintId} hidden>
{labels.hint}
</span>
<ChartTooltip
open={!!tip}
x={tip ? pts[at][0] : 0}
y={-4}
bounds={W}
title={tip ? (at === nowIndex ? `${tip.title} · ${labels.current}` : tip.title) : ''}
value={tip ? <TipValue value={tip.value} format={format} /> : ''}
/>
{region}
</>
)}
{table && !loading && !empty && (
<SrTable
caption={label}
head={[labels.point, labels.value]}
rows={points.map((p, i) => [i === nowIndex ? `${p.title} (${labels.current})` : p.title, formatValue(p.value, format)])}
/>
)}
</div>
)
}
/** The tooltip figure with its unit set muted, as on the big number. */
function TipValue({ value, format }: { value: number; format?: ValueFormat }) {
const p = valueParts(value, format)
const unit = <span className="font-medium text-[color-mix(in_oklab,var(--card)_60%,var(--foreground))]">{p.unit}</span>
return (
<>
{p.sign}
{!p.unitAfter && unit}
{p.whole}
{p.fraction}
{p.unitAfter && <> {unit}</>}
{p.suffix}
</>
)
}Installieren
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/sparkline.jsonMit eingetragener Registry (components.json)
npx shadcn@latest add @manniche/sparkline