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.jsonMed registret tilføjet (components.json)
npx shadcn@latest add @manniche/bar-chart