Mikkel Manniche
Dansk English Deutsch

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.json

With the registry added (components.json)

npx shadcn@latest add @manniche/area-chart

Files

  • area-chart.tsx

Manniche Lab · open source

Free tools for AI agents and web development.