Mikkel Manniche
Dansk English Deutsch

Widgets

Bar chart

Søjlediagram med runde søjler, en prik på i dag, en valgfri bleg sammenligning og dage samlet i uger, når søjlerne bliver for tynde; ét tabulatorstop med piletaster.

Forhåndsvisning

Brug

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>
  )
}

Kilde

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}
    </>
  )
}

Installér

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

Med registret tilføjet (components.json)

npx shadcn@latest add @manniche/bar-chart

Manniche Lab · open source

Gratis værktøjer til AI-agenter og webudvikling.