Widgets
Area chart
A smooth line over a soft tint for a value that moves day by day, with an optional dashed comparison line, a crosshair tooltip that reads the difference, and room under zero for a dip.
Preview
Usage
area-chart-demo.tsx
import { useState } from 'react'
import { AreaChart, type AreaPoint } from '@/components/area-chart'
import { BigNumber, DeltaPill, Pills } from '@/components/chart-kit'
import { BASE, INK } from '@/components/chart-utils'
import { DataTile, TileFact, TileLegend } from '@/components/data-tile'
// Example data for the demo, not a real account. "Today" is Thursday 8 October; the series runs from Wed 9 Sep.
const BALANCE = [
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 BEFORE = [
887.04, 869.89, 803.17, 790.17, 712.16, 666.73, 691.79, 650.74, 628.35, 565.45, 486.93, 422.22, 390.73, 351.67, 300.28, 317.73,
244.86, 182.98, 124.01, 95.55, 61.96, 2462.05, 2418.89, 2327.57, 2248.02, 2180.59, 2154.39, 2160.48, 2112.45, 2062.3,
]
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 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 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 { ...d, day: DAYS[(2 + i) % 7] }
})
const points = (values: number[], previous?: number[]): AreaPoint[] =>
values.map((value, i) => ({
label: i === values.length - 1 ? 'Today' : `${DATES[i].date} ${DATES[i].mon}`,
title: `${DATES[i].day} ${DATES[i].date} ${DATES[i].mon}`,
value,
previous: previous?.[i],
}))
const EUR = { currency: 'EUR', decimals: 2 }
const BALANCE_DATA = points(BALANCE, BEFORE)
const POTS = {
savings: { name: 'Savings pot', data: points(SAVINGS) },
travel: { name: 'Travel pot', data: points(TRAVEL) },
}
type PotId = keyof typeof POTS
const POT_OPTIONS = [
{ id: 'savings', label: 'Savings' },
{ id: 'travel', label: 'Travel' },
]
export default function AreaChartDemo() {
// The figure follows the crosshair, so the chart's selection is controlled here.
const [active, setActive] = useState<number | null>(null)
const [pot, setPot] = useState<PotId>('savings')
const i = active ?? BALANCE.length - 1
const p = POTS[pot]
const last = p.data[p.data.length - 1].value
return (
<div className="@container grid w-full gap-4">
<DataTile
title="Balance"
action={
<TileLegend
items={[
{ label: 'Last 30 days', color: INK, shape: 'line' },
{ label: '30 days before', color: BASE, shape: 'dashed' },
]}
/>
}
footer={<span>Everyday account, end-of-day balance. Example data.</span>}
>
<TileFact
label={active === null ? 'Everyday account balance' : `Balance on ${BALANCE_DATA[i].title}`}
aside={<DeltaPill value={BALANCE[i] - BEFORE[i]} format={{ ...EUR, sign: true }} />}
>
<BigNumber value={BALANCE[i]} size="xl" format={EUR} />
</TileFact>
<AreaChart
className="mt-[22px]"
data={BALANCE_DATA}
label="Everyday account balance, last 30 days compared with the 30 days before"
format={EUR}
tickEvery={7}
activeIndex={active}
onActiveIndexChange={setActive}
labels={{ value: 'Last 30 days', previous: '30 days before' }}
/>
</DataTile>
<div className="grid gap-4 @min-[520px]:grid-cols-2">
<DataTile
title={p.name}
density="compact"
inverted
action={<Pills label="Pot" options={POT_OPTIONS} value={pot} onChange={(id) => setPot(id as PotId)} />}
footer={<span>Example data.</span>}
>
<TileFact aside={<DeltaPill value={((last - p.data[0].value) / p.data[0].value) * 100} format={{ decimals: 1, sign: true, suffix: '%' }} />}>
<BigNumber value={last} size="md" format={EUR} />
</TileFact>
<AreaChart className="mt-3.5" data={p.data} label={`${p.name} balance, last 30 days`} format={EUR} tickEvery={14} />
</DataTile>
<DataTile title="Joint account" density="compact" footer={<span>Opened today. Example data.</span>}>
<TileFact>
<span className="text-muted-foreground">
<BigNumber value={0} size="md" format={EUR} roll={false} />
</span>
</TileFact>
<AreaChart
className="mt-3.5"
data={[]}
label="Joint account balance"
placeholder={
<>
<b className="text-base leading-[1.3] font-semibold">No balance history yet</b>
<span className="max-w-[34ch] text-[13.5px] text-pretty text-muted-foreground">The line starts after the first day closes.</span>
</>
}
/>
</DataTile>
</div>
</div>
)
}Source
area-chart.tsx
// A smooth line over a soft tint for a value that moves day by day, such as an account balance. The line is a monotone
// curve (it never overshoots a point), the tint fills down to zero, and an optional comparison series is drawn as a
// dashed line in BASE. The current point (today) is marked with a dot in --primary. Gridlines are dashed with their
// values at the right and the zero line is solid. A value below zero dips under the baseline into room the scale
// makes for it, instead of running into the labels.
// The plot is one Tab stop. A crosshair follows the pointer, a finger scrubbing sideways or the arrow keys (Home and
// End jump to the ends, Page Up and Page Down by one label step), and the tooltip reads both series and the
// difference; Escape lets go, and the page still scrolls up and down under a finger. Screen readers get the chart's
// name, a hint, one sentence per step and the whole series in a hidden table.
// Only transform and opacity move. On first view, and when the data changes, a clip wipes the lines in from the left
// (300 ms), the tint fades up behind them and the end dot settles last (all done in 600 ms). Under reduced motion the
// whole chart stands at once and the crosshair jumps instead of gliding.
import { useCallback, useEffect, useId, useMemo, useState, type ComponentProps, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { ChartTooltip, SrTable, type TooltipRow } from '@/registry/manniche/chart-kit/chart-kit'
import {
BASE,
EASE_CSS,
GRID,
INK,
formatTick,
formatValue,
niceScale,
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 AreaPoint = {
/** The short name on the axis: "14 Sep", "Today". */
label: string
/** The full name for the tooltip, the table and screen readers: "Mon 14 Sep". Defaults to `label`. */
title?: string
/** The value at this point. May be negative. */
value: number
/** The same point in the period before, drawn as the dashed line. */
previous?: number
}
export type AreaChartLabels = {
/** Added to the current point's name in the tooltip and when read: "Thu 8 Oct · today". Default "today". */
current?: string
/** The main series in the table. Default "This period". */
value?: string
/** The comparison series in the tooltip, the table and when read. Default "Period before". */
previous?: string
/** The difference row in the tooltip. Default "Difference". */
difference?: string
/** The first column of the screen reader table. Default "Day". */
point?: string
/** Read after the chart's name. */
hint?: string
/** Read while loading. Default "Loading". */
loading?: string
/** Shown when there is no data and no `placeholder` is given. Default "No data yet". */
empty?: string
}
const LABELS: Required<AreaChartLabels> = {
current: 'today',
value: 'This period',
previous: 'Period before',
difference: 'Difference',
point: 'Day',
hint: 'Use the arrow keys to read each point. Page Up and Page Down jump further.',
loading: 'Loading',
empty: 'No data yet',
}
export type AreaChartProps = Omit<ComponentProps<'div'>, 'children'> & {
/** One object per point, oldest first: `{ label, title?, value, previous? }`. */
data: AreaPoint[]
/** The chart's accessible name, such as "Everyday account balance, last 30 days". */
label: string
/** How values are written in the tooltip, the table and on the axis. */
format?: ValueFormat
/** Draw each point's `previous` value as a dashed comparison line. Default on when any point has one. */
compare?: boolean
/** The index of the current point (today), marked with the signal dot. Default the last point; null for none. */
current?: number | null
/** The chart's height in px, axis labels included. Default 300, 200 when narrower than 560 px, less when compact. */
height?: number
/** Label every nth point, counted back from the last. Also the Page Up / Page Down step. Default as many as fit, about six. */
tickEvery?: number
/** Show a breathing skeleton instead of data. */
loading?: boolean
/** Shown over the empty frame when there is no data, such as a message and a next step. */
placeholder?: ReactNode
/** 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?: AreaChartLabels
}
/** Room at the right for the value labels, above for the top dot and below for the date labels, as in the mockup. */
const GR = 52
const PT = 10
const PB = 24
const FILL = 'color-mix(in oklab, var(--foreground) 7%, transparent)'
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 smooth line chart with a soft fill, a dashed comparison line, a crosshair and the current point marked. */
export function AreaChart({
data,
label,
format,
compare: compareProp,
current,
height,
tickEvery,
loading = false,
placeholder,
activeIndex,
defaultActiveIndex = null,
onActiveIndexChange,
labels: labelsProp,
className,
style,
...rest
}: AreaChartProps) {
const labels = { ...LABELS, ...labelsProp }
const { ref: frameRef, ...frame } = useChartFrame<HTMLDivElement>()
const { say, region } = useAnnounce()
const hintId = useId()
const clipId = useSvgId('area-reveal')
const [active, setActive] = useControllable(activeIndex, defaultActiveIndex, onActiveIndexChange)
const n = data.length
const compare = compareProp ?? data.some((d) => d.previous !== undefined)
const nowIndex = current === undefined ? n - 1 : current !== null && current >= 0 && current < n ? current : -1
const at = active !== null && active >= 0 && active < n ? active : -1
const empty = !loading && n === 0
const W = frame.width
const H = frame.height
const pw = Math.max(0, W - GR)
const ph = Math.max(0, H - PT - PB)
// The scale. Ticks only ever cover zero and up, unless the data is mostly below zero; a small dip gets room under
// the baseline instead of a whole empty tick.
const { lo, hi, ticks } = useMemo(() => {
const vals = data.flatMap((d) => (compare && d.previous !== undefined ? [d.value, d.previous] : [d.value]))
const top = Math.max(0, ...vals)
const bottom = Math.min(0, ...vals)
const up = niceScale(top || 1, 3)
if (bottom < 0 && (top === 0 || -bottom > up.max * 0.25)) {
const s = niceScale(top, 3, bottom)
return { lo: s.min, hi: s.max, ticks: s.ticks }
}
return { lo: bottom < 0 ? bottom - up.max * 0.04 : 0, hi: up.max, ticks: up.ticks }
}, [data, compare])
const X = (i: number) => (n > 1 ? (i / (n - 1)) * pw : pw / 2)
const Y = (v: number) => PT + (1 - (v - lo) / (hi - lo || 1)) * ph
const paths = useMemo(() => {
if (!pw || !ph || !n) return null
const xs = (i: number) => (n > 1 ? (i / (n - 1)) * pw : pw / 2)
const ys = (v: number) => PT + (1 - (v - lo) / (hi - lo || 1)) * ph
const a = data.map((d, i) => [xs(i), ys(d.value)] as [number, number])
const line = smoothPath(a)
const zero = ys(0)
const fill = `${line}L${a[a.length - 1][0]} ${zero}L${a[0][0]} ${zero}Z`
const withPrev = data.map((d, i) => (d.previous === undefined ? null : ([xs(i), ys(d.previous)] as [number, number])))
const b = compare ? smoothPath(withPrev.filter((p): p is [number, number] => p !== null)) : ''
return { line, fill, b }
}, [data, pw, ph, lo, hi, n, compare])
// The wipe replays whenever the drawing changes: first view, new data, compare on or off.
const shape = useMemo(() => `${compare}|${data.map((d) => `${d.value},${d.previous ?? ''}`).join(';')}`, [data, compare])
const [played, setPlayed] = useState<string | null>(null)
const ready = frame.drawn && W > 0
const shown = frame.reduced ? ready : played === shape
useEffect(() => {
if (!ready || played === shape || frame.reduced) return
let b = 0
const a = requestAnimationFrame(() => {
b = requestAnimationFrame(() => setPlayed(shape))
})
return () => {
cancelAnimationFrame(a)
cancelAnimationFrame(b)
}
}, [ready, played, shape, frame.reduced])
// A point that no longer exists lets go.
useEffect(() => {
if (active !== null && (active < 0 || active >= n)) setActive(null)
}, [active, n, setActive])
const title = (i: number) => data[i].title ?? data[i].label
const describe = (i: number) => {
const d = data[i]
const head = i === nowIndex ? `${title(i)}, ${labels.current}` : title(i)
const prev = compare && d.previous !== undefined ? ` ${labels.previous}: ${formatValue(d.previous, format)}.` : ''
return `${head}: ${formatValue(d.value, format)}.${prev}`
}
const pick = (i: number, announce: boolean) => {
setActive(i)
if (announce) say(describe(i))
}
// Axis labels: as many as fit, counted back from the last point so today is always named.
const longest = Math.max(1, ...data.map((d) => d.label.length))
const need = longest * 6.8 + 16
const dx = n > 1 ? pw / (n - 1) : pw
const every = Math.max(1, Math.ceil(need / Math.max(1, dx)), tickEvery ?? Math.ceil((n - 1) / 6))
const page = tickEvery ?? every
const fromPointer = (e: PointerEvent<HTMLDivElement>) => {
if (!n || !pw) return
const r = e.currentTarget.getBoundingClientRect()
const i = n > 1 ? Math.max(0, Math.min(n - 1, Math.round(((e.clientX - r.left) / pw) * (n - 1)))) : 0
if (i !== at) pick(i, false)
}
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (!n) return
if (e.key === 'Escape') {
if (at < 0) return
e.preventDefault()
setActive(null)
return
}
const from = at < 0 ? (nowIndex >= 0 ? nowIndex : n - 1) : at
const to =
e.key === 'PageUp' ? Math.max(0, from - page) : e.key === 'PageDown' ? Math.min(n - 1, from + page) : stepIndex(e.key, from, n)
if (to === null) return
e.preventDefault()
pick(to, true)
}
const tip = at >= 0 && paths ? data[at] : null
const tipRows: TooltipRow[] = []
if (tip && compare && tip.previous !== undefined) {
tipRows.push({ label: labels.previous, value: formatValue(tip.previous, format), color: BASE })
tipRows.push({ label: labels.difference, value: formatValue(tip.value - tip.previous, { ...format, sign: true }) })
}
const tipY = tip ? Math.min(Y(tip.value), compare && tip.previous !== undefined ? Y(tip.previous) : Infinity) - 6 : 0
const glide = frame.reduced ? 'none' : `transform 110ms ${EASE_CSS}`
const heightClass =
'h-[200px] @min-[560px]:h-[300px] group-data-[density=compact]/tile:h-[150px] @min-[560px]:group-data-[density=compact]/tile:h-[200px]'
const zeroY = Math.round(Y(0)) + 0.5
return (
<div className={cn('@container relative min-w-0', className)} style={style} aria-busy={loading || undefined} {...rest}>
{loading ? (
<div aria-hidden className={cn('relative', heightClass)} style={{ height }}>
<div className="absolute inset-x-0 border-t border-dashed" style={{ top: PT, right: GR, borderColor: GRID }} />
<div
className="absolute left-0 h-[46%] rounded-t-[18px] bg-muted motion-safe:animate-pulse"
style={{ right: GR, bottom: PB }}
/>
<div className="absolute left-0 border-t" style={{ right: GR, bottom: PB, borderColor: BASE }} />
<div className="absolute bottom-0 left-0 flex h-[11px] justify-between" style={{ right: GR }}>
{[0, 1, 2, 3, 4].map((i) => (
<i key={i} className="block h-[9px] w-10 rounded-[10px] bg-muted motion-safe:animate-pulse" />
))}
</div>
</div>
) : (
<div
ref={frameRef}
tabIndex={empty ? undefined : 0}
role={empty ? undefined : 'img'}
aria-label={empty ? undefined : label}
aria-describedby={empty ? undefined : hintId}
onPointerDown={fromPointer}
onPointerMove={fromPointer}
onPointerLeave={(e) => {
if (e.pointerType !== 'mouse') return
if (!e.currentTarget.matches(':focus-visible')) setActive(null)
}}
onFocus={(e) => {
// A press focuses the plot too; only keyboard focus picks the current point.
if (!e.currentTarget.matches(':focus-visible')) return
if (at >= 0) say(describe(at))
else if (n) pick(nowIndex >= 0 ? nowIndex : n - 1, true)
}}
onBlur={() => setActive(null)}
onKeyDown={onKeyDown}
className={cn('relative rounded-[10px] outline-offset-4 select-none [touch-action:pan-y]', !empty && 'cursor-crosshair', heightClass)}
style={{ height }}
>
{W > 0 && H > 0 && (
<svg aria-hidden width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="absolute inset-0 block overflow-visible">
<defs>
<clipPath id={clipId}>
<rect
x={-8}
y={-8}
width={pw + 16}
height={H + 16}
style={{
transformBox: 'fill-box',
transformOrigin: '0 0',
transform: `scaleX(${shown ? 1 : 0})`,
transition: shown && !frame.reduced ? `transform 300ms ${EASE_CSS}` : 'none',
}}
/>
</clipPath>
</defs>
{/* Gridlines with their values at the right; the zero line is solid. */}
{(empty ? [0, 0.5, 1].map((t) => lo + t * (hi - lo)) : ticks).map((t) => {
const y = Math.round(Y(t)) + 0.5
return (
<g key={t}>
<line x1={0} x2={pw} y1={y} y2={y} stroke={t === 0 ? BASE : GRID} strokeDasharray={t === 0 ? undefined : '2 4'} />
{!empty && (
<text x={pw + 10} y={y + 3.5} className="fill-muted-foreground font-mono text-[11px] tabular-nums">
{formatTick(t, format)}
</text>
)}
</g>
)
})}
{!ticks.includes(0) && !empty && <line x1={0} x2={pw} y1={zeroY} y2={zeroY} stroke={BASE} />}
{paths && (
<g clipPath={`url(#${clipId})`}>
<path
d={paths.fill}
fill={FILL}
style={{
opacity: shown ? 1 : 0,
transition: shown && !frame.reduced ? `opacity 300ms ${EASE_CSS} 60ms` : 'none',
}}
/>
{paths.b && (
<path d={paths.b} fill="none" stroke={BASE} strokeWidth={1.75} strokeDasharray="5 5" strokeLinecap="round" />
)}
<path d={paths.line} fill="none" stroke={INK} strokeWidth={2.25} strokeLinejoin="round" strokeLinecap="round" />
</g>
)}
{/* The current point's signal dot settles once the line has reached it. */}
{paths && nowIndex >= 0 && (
<circle
cx={X(nowIndex)}
cy={Y(data[nowIndex].value)}
r={5.5}
fill="var(--primary)"
stroke="var(--card)"
strokeWidth={3}
style={{
transformBox: 'fill-box',
transformOrigin: 'center',
opacity: shown ? 1 : 0,
transform: shown ? 'none' : 'scale(0.4)',
transition: shown && !frame.reduced ? `opacity 300ms ${EASE_CSS} 260ms, transform 300ms ${EASE_CSS} 260ms` : 'none',
}}
/>
)}
{/* Date labels, counted back from the last point. Labels at the edges anchor inwards. */}
{pw > 0 &&
data.map((d, i) => {
if ((n - 1 - i) % every !== 0) return null
const x = X(i)
const w = d.label.length * 6.8
const anchor = n > 1 && x < w / 2 ? 'start' : n > 1 && x > pw - w / 2 ? 'end' : 'middle'
return (
<text
key={i}
x={x}
y={H - 6}
textAnchor={anchor}
className={cn(
'font-mono text-[11px] tabular-nums',
i === nowIndex ? 'fill-foreground font-medium' : 'fill-muted-foreground',
)}
>
{d.label}
</text>
)
})}
{/* The crosshair: a hairline and a dot on each series, gliding between points. */}
{paths && (
<g style={{ opacity: tip ? 1 : 0, transition: frame.reduced ? 'none' : `opacity 140ms ${EASE_CSS}` }}>
<line
x1={0}
x2={0}
y1={PT}
y2={H - PB}
stroke="var(--foreground)"
strokeOpacity={0.35}
style={{ transform: `translateX(${X(Math.max(0, at))}px)`, transition: glide }}
/>
{compare && tip?.previous !== undefined && (
<circle
r={4.5}
fill="var(--card)"
stroke={BASE}
strokeWidth={2}
style={{ transform: `translate(${X(at)}px, ${Y(tip.previous)}px)`, transition: glide }}
/>
)}
<circle
r={6}
fill="var(--foreground)"
stroke="var(--card)"
strokeWidth={3}
style={{ transform: `translate(${X(Math.max(0, at))}px, ${Y(tip ? tip.value : 0)}px)`, transition: glide }}
/>
</g>
)}
</svg>
)}
{empty && (
<div className="absolute inset-0 grid place-content-center justify-items-center gap-1.5 px-4 text-center" style={{ bottom: PB }}>
{placeholder ?? <span className="text-[13.5px] text-muted-foreground">{labels.empty}</span>}
</div>
)}
</div>
)}
<span id={hintId} hidden>
{labels.hint}
</span>
{loading && <span className="sr-only">{labels.loading}</span>}
{!loading && !empty && (
<>
<ChartTooltip
open={!!tip}
x={tip ? X(at) : 0}
y={tipY}
below={tipY < 20}
bounds={W}
title={tip ? (at === nowIndex ? `${title(at)} · ${labels.current}` : title(at)) : ''}
value={tip ? <TipValue value={tip.value} format={format} /> : ''}
rows={tipRows}
/>
<SrTable
caption={label}
head={compare ? [labels.point, labels.value, labels.previous] : [labels.point, labels.value]}
rows={data.map((d, i) => {
const name = i === nowIndex ? `${title(i)} (${labels.current})` : title(i)
return compare
? [name, formatValue(d.value, format), d.previous === undefined ? '' : formatValue(d.previous, format)]
: [name, formatValue(d.value, format)]
})}
/>
{region}
</>
)}
</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}
</>
)
}Install
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/area-chart.jsonWith the registry added (components.json)
npx shadcn@latest add @manniche/area-chart