Mikkel Manniche
Dansk English Deutsch

Widgets

Bar chart

Säulendiagramm mit runden Säulen, einem Punkt auf heute, optionalem blassem Vergleich und Tagen, die zu Wochen zusammengefasst werden, wenn die Säulen zu dünn werden; ein Tab-Stopp mit Pfeiltasten.

Vorschau

Verwendung

bar-chart-demo.tsx
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { BarChart, type BarPoint } from '@/components/bar-chart'
import { BigNumber, DeltaPill, Pills } from '@/components/chart-kit'
import { BASE } from '@/components/chart-utils'
import { DataTile, TileFact } from '@/components/data-tile'

// Example data for the demo, not real spending. "Today" is Thursday 8 October.
const WEEK = {
  value: [38.4, 96.1, 54.75, 22.3, 42.1, 18.6, 64.2],
  previous: [44, 71.2, 88.5, 30.1, 25.9, 40.3, 52],
  label: ['Fri', 'Sat', 'Sun', 'Mon', 'Tue', 'Wed', 'Thu'],
  title: ['Fri 2 Oct', 'Sat 3 Oct', 'Sun 4 Oct', 'Mon 5 Oct', 'Tue 6 Oct', 'Wed 7 Oct', 'Thu 8 Oct'],
}
const MONTH_VALUE = [
  33.39, 34.06, 72.09, 79.8, 51.12, 36.94, 31.12, 32.22, 45.66, 67.06, 64.45, 48.63, 29.11, 28.41, 36.01, 19.18, 71.35, 71.2,
  83.69, 26.21, 37.51, 23.78, 16.55, 86.34, 49.88, 71.24, 34.93, 35.79, 45.73, 19.75,
]
const MONTH_PREVIOUS = [
  18.06, 17.15, 66.72, 53, 78.01, 45.43, 14.94, 41.05, 22.39, 62.9, 78.52, 64.71, 31.49, 39.06, 51.39, 22.55, 72.87, 61.88,
  58.97, 28.46, 33.59, 49.91, 43.16, 91.32, 79.55, 67.43, 26.2, 33.91, 48.03, 50.15,
]
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
// 30 days back from Thu 8 Oct: Wed 9 Sep … Thu 8 Oct.
const MONTH_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 YEAR = {
  value: [1469.71, 1764.55, 1272.3, 1441.74, 1215.76, 1428.71, 1482.44, 1372.4, 1560.36, 1276.36, 1507.54, 412.65],
  previous: [1381.43, 1351.01, 1105.25, 1276.85, 1214.17, 1440.69, 1209.48, 1387.01, 1374.92, 1196.36, 1107.23, 1401.53],
  label: ['Nov', 'Dec', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct'],
}

const zip = (value: number[], previous: number[], label: string[], title: string[]): BarPoint[] =>
  value.map((v, i) => ({ value: v, previous: previous[i], label: label[i], title: title[i] }))

const PERIODS = {
  week: {
    fact: 'Spent in the last 7 days',
    previous: 'Previous 7 days',
    data: zip(WEEK.value, WEEK.previous, WEEK.label, WEEK.title),
    bucket: 7,
    tickEvery: 1,
  },
  month: {
    fact: 'Spent in the last 30 days',
    previous: 'Previous 30 days',
    data: zip(
      MONTH_VALUE,
      MONTH_PREVIOUS,
      MONTH_DATES.map((d) => String(d.date)),
      MONTH_DATES.map((d) => `${d.day} ${d.date} ${d.mon}`),
    ),
    bucket: 7,
    tickEvery: 7,
  },
  year: {
    fact: 'Spent in the last 12 months',
    previous: 'Previous 12 months',
    data: zip(
      YEAR.value,
      YEAR.previous,
      YEAR.label,
      YEAR.label.map((m, i) => `${m} ${i < 2 ? 2025 : 2026}${i === 11 ? ', to date' : ''}`),
    ),
    bucket: 3,
    tickEvery: 1,
  },
}
type PeriodId = keyof typeof PERIODS
const OPTIONS = [
  { id: 'week', label: 'Week' },
  { id: 'month', label: 'Month' },
  { id: 'year', label: 'Year' },
]

// Groups of seven days are named by their first day; a short group at the start says its range.
const weekTitle = (points: BarPoint[], start: number) => {
  const first = MONTH_DATES[start]
  const last = MONTH_DATES[start + points.length - 1]
  return points.length === 7 ? `Week of ${first.date} ${first.mon}` : `${first.date}–${last.date} ${last.mon}`
}
const weekLabel = (_: BarPoint[], start: number) => `${MONTH_DATES[start].date} ${MONTH_DATES[start].mon}`

const sum = (xs: number[]) => xs.reduce((s, x) => s + x, 0)
const EUR = { currency: 'EUR', decimals: 2 }

/** The mockup's Compare switch: a pill with a box that fills and ticks when pressed. */
function CompareToggle({ pressed, onPressedChange }: { pressed: boolean; onPressedChange: (on: boolean) => void }) {
  return (
    <button
      type="button"
      aria-pressed={pressed}
      onClick={() => onPressedChange(!pressed)}
      className={cn(
        'inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-full bg-muted pr-3.5 pl-2.5 text-[13.5px] font-medium',
        pressed ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
      )}
    >
      <span aria-hidden className="relative size-4 rounded-[6px]" style={{ boxShadow: `inset 0 0 0 1.5px ${BASE}` }}>
        <span
          className="absolute inset-0 rounded-[6px] bg-foreground transition-opacity duration-150 ease-out-quint motion-reduce:transition-none"
          style={{ opacity: pressed ? 1 : 0 }}
        />
        <span
          className="absolute top-0.5 left-[5px] h-2 w-1 border-r-2 border-b-2 border-card transition-[opacity,transform] duration-200 ease-out-quint motion-reduce:transition-none"
          style={{ opacity: pressed ? 1 : 0, transform: `rotate(45deg) scale(${pressed ? 1 : 0.4})` }}
        />
      </span>
      Compare
    </button>
  )
}

export default function BarChartDemo() {
  const [period, setPeriod] = useState<PeriodId>('week')
  const [compare, setCompare] = useState(false)
  const p = PERIODS[period]
  const total = sum(p.data.map((d) => d.value))
  const before = sum(p.data.map((d) => d.previous ?? 0))
  const month = PERIODS.month
  return (
    <div className="@container grid w-full gap-4">
      <DataTile
        title="Spending"
        action={
          <>
            <CompareToggle pressed={compare} onPressedChange={setCompare} />
            <Pills label="Period" options={OPTIONS} value={period} onChange={(id) => setPeriod(id as PeriodId)} />
          </>
        }
        footer={<span>Card payments on all cards. Example data.</span>}
      >
        <TileFact label={p.fact} aside={<DeltaPill value={((total - before) / before) * 100} format={{ decimals: 1, sign: true, suffix: '%' }} goodWhen="down" />}>
          <BigNumber value={total} size="xl" format={EUR} />
        </TileFact>
        <BarChart
          className="mt-[22px]"
          data={p.data}
          label={`Spending, ${p.fact.replace('Spent in the', '').trim()}`}
          format={EUR}
          compare={compare}
          bucket={p.bucket}
          tickEvery={p.tickEvery}
          bucketTitle={period === 'month' ? weekTitle : undefined}
          bucketLabel={period === 'month' ? weekLabel : undefined}
          labels={{ previous: p.previous, value: 'This period', point: 'Period' }}
        />
      </DataTile>

      <div className="grid gap-4 @min-[520px]:grid-cols-2">
        <DataTile title="Last 30 days" density="compact" footer={<span>Grouped by week when narrow. Example data.</span>}>
          <TileFact>
            <BigNumber value={sum(MONTH_VALUE)} size="md" format={EUR} />
          </TileFact>
          <BarChart
            className="mt-3.5"
            data={month.data}
            label="Spending, last 30 days"
            format={EUR}
            legend={false}
            tickEvery={7}
            bucketTitle={weekTitle}
            bucketLabel={weekLabel}
          />
        </DataTile>
        <DataTile title="This year" density="compact" inverted footer={<span>Example data.</span>}>
          <TileFact aside={<DeltaPill value={6.1} goodWhen="down" />}>
            <BigNumber value={sum(YEAR.value)} size="md" format={{ currency: 'EUR' }} />
          </TileFact>
          <BarChart
            className="mt-3.5"
            data={PERIODS.year.data}
            label="Spending, last 12 months, compared with the 12 months before"
            format={{ currency: 'EUR' }}
            compare
            bucket={3}
            labels={{ previous: 'Year before' }}
          />
        </DataTile>
      </div>

      <div className="grid gap-4 @min-[520px]:grid-cols-2">
        <DataTile title="No data" density="compact">
          <TileFact>
            <span className="text-muted-foreground">
              <BigNumber value={0} size="md" format={EUR} roll={false} />
            </span>
          </TileFact>
          <BarChart
            className="mt-3.5"
            data={WEEK.label.map((label) => ({ label, value: 0 }))}
            label="Spending, last 7 days"
            format={EUR}
            current={null}
            placeholder={
              <>
                <b className="text-base leading-[1.3] font-semibold">No spending yet</b>
                <span className="max-w-[34ch] text-[13.5px] text-pretty text-muted-foreground">
                  Payments show up here as soon as a card is used.
                </span>
              </>
            }
          />
        </DataTile>
        <DataTile title="Loading" density="compact">
          <TileFact>
            <span aria-hidden className="block h-[30px] w-36 rounded-xl bg-muted motion-safe:animate-pulse" />
          </TileFact>
          <BarChart className="mt-3.5" data={[]} label="Spending, last 7 days" loading labels={{ loading: 'Loading spending for the last 7 days' }} />
        </DataTile>
      </div>
    </div>
  )
}

Quellcode

bar-chart.tsx
// A column chart for one value per period: chunky bars with round caps that rise from a solid baseline, dashed
// gridlines with their values at the right, and the current point (today) marked by a small dot in --primary.
// `compare` draws the period before as a pale bar beside each one. When the bars would get thinner than
// `minBarWidth`, the points are grouped into buckets (seven days become a week), counted back from the latest
// point, and the tooltip, the screen reader table and the announcements speak the buckets.
// The plot is one Tab stop. Arrow keys, Home and End step through the bars, Escape lets go; pointer hover and a finger
// scrubbing sideways show the same tooltip, and the page still scrolls up and down under a finger. Screen readers get
// the chart's name, a hint, one short sentence per step, and the whole series in a hidden table.
// Bars only move by transform and fade by opacity. On first view they rise in a short stagger (all done in 600 ms);
// a new period or turning compare on replays it, and new values on the same bars glide. Under reduced motion every
// bar stands at its height at once.
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,
  INK_GHOST,
  formatTick,
  formatValue,
  niceScale,
  stepIndex,
  valueParts,
  type ValueFormat,
} from '@/registry/manniche/chart-kit/chart-utils'
import { useAnnounce, useChartFrame } from '@/registry/manniche/chart-kit/use-chart'
import { TileLegend } from '@/registry/manniche/data-tile/data-tile'

export type BarPoint = {
  /** The short name under the bar: "Thu", "14", "Oct". */
  label: string
  /** The full name for the tooltip, the table and screen readers: "Thu 8 Oct". Defaults to `label`. */
  title?: string
  /** The bar's value, 0 or more. */
  value: number
  /** The same slot in the period before, drawn beside the bar when `compare` is on. */
  previous?: number
}

export type BarChartLabels = {
  /** Added to the current point's name in the tooltip and when read: "Thu 8 Oct · today". Default "today". */
  current?: string
  /** The current point in the legend. Default "Today". */
  currentKey?: string
  /** The main series in the legend and the table. Default "This period". */
  value?: string
  /** The comparison series. Default "Period before". */
  previous?: string
  /** The change row in the tooltip. Default "Change". */
  change?: string
  /** The first column of the screen reader table. Default "Period". */
  point?: string
  /** Read after the chart's name. Default "Use the arrow keys to read each bar." */
  hint?: string
  /** Read while loading. Default "Loading". */
  loading?: string
  /** Shown when there is nothing to draw and no `placeholder` is given. Default "No data yet". */
  empty?: string
  /** Names a group of points from its first and last title. Default "Wed 9 Sep – Tue 15 Sep". */
  range?: (first: string, last: string) => string
}

const LABELS: Required<BarChartLabels> = {
  current: 'today',
  currentKey: 'Today',
  value: 'This period',
  previous: 'Period before',
  change: 'Change',
  point: 'Period',
  hint: 'Use the arrow keys to read each bar.',
  loading: 'Loading',
  empty: 'No data yet',
  range: (first, last) => `${first} – ${last}`,
}

export type BarChartProps = Omit<ComponentProps<'div'>, 'children'> & {
  /** One object per period, oldest first: `{ label, title?, value, previous? }`. */
  data: BarPoint[]
  /** The chart's accessible name, such as "Spending, last 7 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 pale bar beside it, with a legend. Default false. */
  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 plot's height in px. Default 208, 170 when narrow, and lower in a compact tile. */
  height?: number
  /** The thinnest a bar may be drawn, in px. Thinner bars are grouped instead. Default 10. */
  minBarWidth?: number
  /** How many points make one group when bars get too thin, such as 7 for days into weeks. False never groups. Default 7. */
  bucket?: number | false
  /** Names a group for the tooltip, the table and screen readers: "Week of 14 Sep". Gets the points and the first one's index. */
  bucketTitle?: (points: BarPoint[], start: number) => string
  /** The short name under a group's bar. Default the first point's label. */
  bucketLabel?: (points: BarPoint[], start: number) => string
  /** Label every nth bar, counted back from the last. Default as many as fit, at most about ten. */
  tickEvery?: number
  /** Show breathing skeleton bars instead of data. */
  loading?: boolean
  /** Shown over the empty frame when there is no data or every value is 0, such as a message and a next step. */
  placeholder?: ReactNode
  /** Show the key under the chart: the series drawn and the current-point dot. Default on when there is a
   * comparison or a current point to explain; a series that is not drawn is never listed. */
  legend?: boolean
  /** The highlighted point (an index into `data`), or null. When grouped, the first index of the group. */
  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?: BarChartLabels
}

/** The space at the right for the value labels, as in the mockup. */
const GUT = 46
const SKELETON = [46, 78, 58, 30, 50, 24, 66]

type Slot = { label: string; title: string; value: number; previous?: number; start: number; end: number }

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
}

/** Bar width and gap for n slots across the plot. */
function measure(n: number, plot: number, compare: boolean) {
  const slot = plot / Math.max(1, n)
  const gap = Math.max(3, Math.min(10, Math.round(slot * 0.12)))
  const inner = slot - gap
  const bar = compare ? Math.min(26, inner * 0.44) : Math.min(48, inner * 0.64)
  return { slot, bar }
}

/**
 * A column chart with round-capped bars, a signal dot on the current point and an optional comparison period.
 * Groups the points into buckets when the bars would be too thin to read.
 */
export function BarChart({
  data,
  label,
  format,
  compare = false,
  current,
  height,
  minBarWidth = 10,
  bucket = 7,
  bucketTitle,
  bucketLabel,
  tickEvery,
  loading = false,
  placeholder,
  legend,
  activeIndex,
  defaultActiveIndex = null,
  onActiveIndexChange,
  labels: labelsProp,
  className,
  style,
  ...rest
}: BarChartProps) {
  const labels = { ...LABELS, ...labelsProp }
  const { ref: frameRef, ...frame } = useChartFrame<HTMLDivElement>()
  const { say, region } = useAnnounce()
  const hintId = useId()
  const [active, setActive] = useControllable(activeIndex, defaultActiveIndex, onActiveIndexChange)
  const nowIndex = current === undefined ? data.length - 1 : current
  const width = frame.width
  const plotW = Math.max(0, width - GUT)
  // The frame holds the plot and the 20 px row of labels under it.
  const plotH = Math.max(0, frame.height - 20)

  // Group the points when the bars would be thinner than promised. Groups are counted back from the last point,
  // so the newest group is always whole and the oldest may be short.
  const size = useMemo(() => {
    if (!plotW || !bucket || bucket < 2) return 1
    let s = 1
    for (let k = 1; k < 64; k++) {
      if (measure(Math.ceil(data.length / s), plotW, compare).bar >= minBarWidth || Math.ceil(data.length / s) <= 1) break
      s = bucket * k
    }
    return s
  }, [plotW, bucket, data.length, compare, minBarWidth])

  const range = labelsProp?.range ?? LABELS.range
  const slots: Slot[] = useMemo(() => {
    const n = data.length
    if (size === 1)
      return data.map((d, i) => ({ label: d.label, title: d.title ?? d.label, value: d.value, previous: d.previous, start: i, end: i }))
    const out: Slot[] = []
    for (let end = n - 1; end >= 0; end -= size) {
      const start = Math.max(0, end - size + 1)
      const pts = data.slice(start, end + 1)
      const hasPrev = pts.some((p) => p.previous !== undefined)
      const first = pts[0].title ?? pts[0].label
      const last = pts[pts.length - 1].title ?? pts[pts.length - 1].label
      out.unshift({
        label: bucketLabel ? bucketLabel(pts, start) : pts[0].label,
        title: bucketTitle ? bucketTitle(pts, start) : pts.length === 1 ? first : range(first, last),
        value: pts.reduce((s, p) => s + p.value, 0),
        previous: hasPrev ? pts.reduce((s, p) => s + (p.previous ?? 0), 0) : undefined,
        start,
        end,
      })
    }
    return out
  }, [data, size, bucketTitle, bucketLabel, range])

  const n = slots.length
  const slotOf = (index: number | null) => (index === null || index < 0 ? -1 : slots.findIndex((s) => index >= s.start && index <= s.end))
  const nowSlot = nowIndex === null ? -1 : slotOf(nowIndex)
  const at = slotOf(active)
  const empty = !loading && (n === 0 || slots.every((s) => !s.value && !(compare && s.previous)))

  const max = Math.max(0, ...slots.map((s) => Math.max(s.value, compare ? (s.previous ?? 0) : 0)))
  const scale = niceScale(max || 1, 2)
  const top = scale.max
  const ticks = empty ? [0, 0.5, 1] : scale.ticks.map((t) => t / top)
  const { slot, bar } = measure(n, plotW, compare)

  // First view and every new shape (period, grouping, compare) rise from the baseline; new values on the same bars glide.
  const shape = `${n}|${compare}|${size}`
  const [played, setPlayed] = useState<string | null>(null)
  const ready = frame.drawn && width > 0
  const risen = 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 >= data.length)) setActive(null)
  }, [active, data.length, setActive])

  const describe = (i: number) => {
    const s = slots[i]
    const head = i === nowSlot ? `${s.title}, ${labels.current}` : s.title
    const prev = compare && s.previous !== undefined ? `. ${labels.previous}: ${formatValue(s.previous, format)}` : ''
    return `${head}: ${formatValue(s.value, format)}${prev}`
  }
  const pick = (i: number, announce: boolean) => {
    setActive(slots[i].start)
    if (announce) say(describe(i))
  }

  const fromPointer = (e: PointerEvent<HTMLDivElement>) => {
    if (!n || empty || loading) return
    const r = e.currentTarget.getBoundingClientRect()
    const i = Math.max(0, Math.min(n - 1, Math.floor((e.clientX - r.left) / Math.max(1, slot))))
    if (i !== at) pick(i, false)
  }
  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (!n || empty) return
    if (e.key === 'Escape') {
      if (at < 0) return
      e.preventDefault()
      setActive(null)
      return
    }
    const to = stepIndex(e.key, at < 0 ? (nowSlot >= 0 ? nowSlot : n - 1) : at, n)
    if (to === null) return
    e.preventDefault()
    pick(to, true)
  }

  // Ticks: as many labels as fit, counted back from the last bar so the current one is always named.
  const longest = Math.max(1, ...slots.map((s) => s.label.length))
  const fit = Math.ceil((longest * 6.8 + 10) / Math.max(1, slot))
  const every = Math.max(1, fit, size === 1 && tickEvery ? tickEvery : size === 1 ? Math.ceil(n / 10) : 1)

  const tip = at >= 0 && !empty && !loading && width > 0 ? slots[at] : null
  const tipRows: TooltipRow[] = []
  if (tip && compare && tip.previous !== undefined) {
    tipRows.push({ label: labels.previous, value: formatValue(tip.previous, format), color: INK_GHOST })
    if (tip.previous)
      tipRows.push({
        label: labels.change,
        value: formatValue(((tip.value - tip.previous) / tip.previous) * 100, { locale: format?.locale, decimals: 1, sign: true, suffix: '%' }),
      })
  }
  const tipY = tip
    ? Math.min(
        plotH * (1 - tip.value / top) - (at === nowSlot ? 18 : 0),
        compare && tip.previous !== undefined ? plotH * (1 - tip.previous / top) : Infinity,
      )
    : 0

  const showLegend = (legend ?? (compare || nowSlot >= 0)) && !loading && !empty
  const stagger = Math.min(26, 300 / Math.max(1, n))
  const glide = (k: number, extra = 0) =>
    risen && !frame.reduced
      ? {
          transitionProperty: 'transform, opacity',
          transitionDuration: '300ms, 160ms',
          transitionTimingFunction: EASE_CSS,
          transitionDelay: `${Math.round(k * stagger + extra)}ms, 0ms`,
        }
      : { transition: 'none' }

  const heightClass =
    'h-[170px] @min-[448px]:h-[208px] group-data-[density=compact]/tile:h-[132px] @min-[448px]:group-data-[density=compact]/tile:h-[150px]'

  return (
    <div className={cn('@container relative min-w-0', className)} style={style} aria-busy={loading || undefined} {...rest}>
      {loading ? (
        <div aria-hidden>
          <div
            className={cn('flex items-end gap-2 border-b motion-safe:animate-pulse', heightClass)}
            style={{ marginRight: GUT, borderColor: GRID, height }}
          >
            {(data.length ? data.slice(-12) : SKELETON).map((_, i) => (
              <span key={i} className="flex h-full flex-1 items-end justify-center">
                <i className="block w-[64%] max-w-12 rounded-[999px_999px_3px_3px] bg-muted" style={{ height: `${SKELETON[i % 7]}%` }} />
              </span>
            ))}
          </div>
          <div className="mt-[9px] flex gap-2" style={{ marginRight: GUT }}>
            {(data.length ? data.slice(-12) : SKELETON).map((_, i) => (
              <span key={i} className="flex flex-1 justify-center">
                <i className="block h-[9px] w-6 rounded-[10px] bg-muted motion-safe:animate-pulse" />
              </span>
            ))}
          </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))
              return
            }
            const i = nowSlot >= 0 ? nowSlot : n - 1
            if (i >= 0) pick(i, true)
          }}
          onBlur={() => setActive(null)}
          onKeyDown={onKeyDown}
          className={cn('relative rounded-xl outline-offset-4 select-none [touch-action:pan-y]', !empty && 'cursor-crosshair')}
        >
          <div className={cn('relative', heightClass)} style={{ marginRight: GUT, height }}>
            {/* Gridlines with their values at the right; the baseline is solid. */}
            {ticks.map((t, i) => (
              <div
                key={`${i}-${t}`}
                aria-hidden
                className="absolute left-0 h-0 border-t"
                style={{
                  bottom: `${t * 100}%`,
                  right: 0,
                  borderTopStyle: t === 0 ? 'solid' : 'dashed',
                  borderTopColor: t === 0 ? BASE : GRID,
                }}
              >
                {!empty && (
                  <span
                    className="absolute top-[-6px] font-mono text-[11px] leading-none whitespace-nowrap text-muted-foreground tabular-nums"
                    style={{ left: `calc(100% + 8px)` }}
                  >
                    {formatTick(t * top, format)}
                  </span>
                )}
              </div>
            ))}
            {!empty &&
              width > 0 &&
              slots.map((s, i) => {
                const isNow = i === nowSlot
                const dim = at >= 0 && at !== i
                const v = risen ? Math.max(0, s.value) / top : 0
                const p = risen ? Math.max(0, s.previous ?? 0) / top : 0
                const two = compare && s.previous !== undefined
                const groupW = two ? bar * 2 + 3 : bar
                return (
                  <div
                    key={i}
                    aria-hidden
                    className="absolute top-0 bottom-0 flex justify-center gap-[3px]"
                    style={{ left: i * slot + slot / 2 - groupW / 2, width: groupW }}
                  >
                    {two && (
                      <span className="relative block h-full overflow-hidden rounded-b-[3px]" style={{ width: bar }}>
                        <i
                          className="absolute inset-0 rounded-[999px_999px_3px_3px]"
                          style={{ background: INK_GHOST, transform: `translateY(${(1 - p) * 100}%)`, opacity: dim ? 0.42 : 1, ...glide(i) }}
                        />
                      </span>
                    )}
                    <span className="relative block h-full" style={{ width: bar }}>
                      <span className="absolute inset-0 overflow-hidden rounded-b-[3px]">
                        <i
                          className="absolute inset-0 rounded-[999px_999px_3px_3px]"
                          style={{ background: INK, transform: `translateY(${(1 - v) * 100}%)`, opacity: dim ? 0.42 : 1, ...glide(i) }}
                        />
                      </span>
                      {isNow && (
                        <span
                          className="absolute inset-0"
                          style={{ transform: `translateY(${(1 - v) * 100}%)`, ...glide(i) }}
                        >
                          <span
                            className="absolute top-[-18px] left-1/2 -ml-[5px] block size-2.5 rounded-full bg-primary shadow-[0_0_0_3px_var(--card)]"
                            style={
                              frame.reduced
                                ? undefined
                                : {
                                    opacity: risen ? 1 : 0,
                                    transform: risen ? 'none' : 'translateY(6px)',
                                    transitionProperty: risen ? 'opacity, transform' : 'none',
                                    transitionDuration: '300ms',
                                    transitionTimingFunction: EASE_CSS,
                                    transitionDelay: '300ms',
                                  }
                            }
                          />
                        </span>
                      )}
                    </span>
                  </div>
                )
              })}
            {empty && (
              <div className="absolute inset-0 grid place-content-center justify-items-center gap-1.5 px-4 text-center" style={{ marginRight: -GUT }}>
                {placeholder ?? <span className="text-[13.5px] text-muted-foreground">{labels.empty}</span>}
              </div>
            )}
          </div>
          <div aria-hidden className="relative mt-[9px] h-[11px]" style={{ marginRight: GUT }}>
            {width > 0 &&
              slots.map((s, i) =>
                (n - 1 - i) % every === 0 ? (
                  <span
                    key={i}
                    className={cn(
                      'absolute top-0 -translate-x-1/2 font-mono text-[11px] leading-none whitespace-nowrap tabular-nums',
                      i === nowSlot ? 'font-medium text-foreground' : 'text-muted-foreground',
                    )}
                    style={{ left: i * slot + slot / 2 }}
                  >
                    {s.label}
                  </span>
                ) : null,
              )}
          </div>
        </div>
      )}
      <span id={hintId} hidden>
        {labels.hint}
      </span>
      {loading && <span className="sr-only">{labels.loading}</span>}
      {!loading && !empty && (
        <>
          <ChartTooltip
            open={!!tip}
            x={tip ? at * slot + slot / 2 : 0}
            y={tipY}
            bounds={width}
            title={tip ? (at === nowSlot ? `${tip.title} · ${labels.current}` : tip.title) : ''}
            value={tip ? <TipValue value={tip.value} format={format} /> : ''}
            rows={tipRows}
          />
          {showLegend && (
            <TileLegend
              className="mt-3"
              items={[
                { label: labels.value, color: INK },
                ...(compare ? [{ label: labels.previous, color: INK_GHOST }] : []),
                ...(nowSlot >= 0 ? [{ label: labels.currentKey, color: 'var(--primary)', shape: 'dot' as const }] : []),
              ]}
            />
          )}
          <SrTable
            caption={label}
            head={compare ? [labels.point, labels.value, labels.previous] : [labels.point, labels.value]}
            rows={slots.map((s) => {
              const name = slots.indexOf(s) === nowSlot ? `${s.title} (${labels.current})` : s.title
              return compare ? [name, formatValue(s.value, format), s.previous === undefined ? '' : formatValue(s.previous, format)] : [name, formatValue(s.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}
    </>
  )
}

Installieren

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

Mit eingetragener Registry (components.json)

npx shadcn@latest add @manniche/bar-chart

Manniche Lab · Open Source

Kostenlose Werkzeuge für KI-Agenten und Webentwicklung.