BarChart

Compare categorical values in a responsive SVG chart, including positive and negative data.

BarChart

View source · API reference

Playground

Change the dataset, edit each daily value, and see the chart update immediately. Try an all-negative balance, set scale bounds, switch colors and units, or remove the data entirely. Reset restores every control.

<RegorApp id="bar-chart-demo" src="./playground.ts"/>
import {
  type BarChartItem,
  defineBarChartComponents,
  defineButtonComponents,
  defineFlexComponents,
  defineFormComponents,
  defineFormInputField,
  defineFormSelectField,
  defineGridComponents,
  defineIconComponents,
  definePanelComponents,
  type FormSelectOption,
} from '@purestack/ts-components'
import { getThemePaletteVar } from '@purestack/ts-style'
import { lucide_chevron_down } from '@purestack/ts-svg-icons'
import {
  batch,
  type ComputedRef,
  computed,
  createApp,
  defineComponent,
  html,
  type Ref,
  ref,
  type SRef,
  sref,
} from 'regor'

export type BarChartPreset = 'mixed' | 'positive' | 'negative' | 'zero'
export type BarChartPalette = 'balance' | 'categorical' | 'accent'

export interface BarChartEditorRow {
  id: string
  label: string
  value: Ref<number | string>
}

export interface BarChartPlayground {
  rows: SRef<BarChartEditorRow[]>
  chartItems: ComputedRef<BarChartItem[]>
  summary: ComputedRef<string>
  preset: Ref<BarChartPreset>
  palette: Ref<BarChartPalette>
  suffix: Ref<string>
  minimum: Ref<number | string>
  maximum: Ref<number | string>
  height: Ref<number | string>
  valuesVisible: Ref<boolean>
  axisVisible: Ref<boolean>
  labelsVisible: Ref<boolean>
  motionEnabled: Ref<boolean>
  presets: FormSelectOption[]
  palettes: FormSelectOption[]
  units: FormSelectOption[]
  applyPreset: (event: Event) => void
  clear: () => void
  reset: () => void
}

const barChartPlaygroundTemplate = html`<Flex direction="column">
  <Grid columns="1" columnsMd="3">
    <FormSelectField id="bar-preset" label="Dataset" :model="preset" :options="presets" @change="applyPreset" />
    <FormSelectField id="bar-palette" label="Bar colors" :model="palette" :options="palettes" />
    <FormSelectField id="bar-unit" label="Value suffix" :model="suffix" :options="units" />
  </Grid>
  <Panel variant="surfaceAlt" bodyClass="p-3 min-w-0">
    <p class="text-eyebrow mt-0">LIVE PREVIEW · WEEKLY BALANCE</p>
    <div class="overflow-x-auto" tabindex="0" role="region" aria-label="Scrollable chart preview">
      <BarChart
        :items="chartItems"
        title="Weekly balance"
        description="Illustrative daily changes. Edit the exact values in the fields below."
        :ariaLabel="summary"
        emptyLabel="No days to compare"
        :valueSuffix="suffix"
        :minValue="minimum"
        :maxValue="maximum"
        :height="height"
        :showValues="valuesVisible"
        :showAxis="axisVisible"
        :showLabels="labelsVisible"
        :animated="motionEnabled"
        style="min-width: 440px"
      />
    </div>
    <FormStatus>{{ summary }}</FormStatus>
  </Panel>
  <Flex wrap="true">
    <FormCheck id="bar-values" label="Values" :checked="valuesVisible" />
    <FormCheck id="bar-axis" label="Grid and zero line" :checked="axisVisible" />
    <FormCheck id="bar-labels" label="Category labels" :checked="labelsVisible" />
    <FormCheck id="bar-motion" label="Entrance animation" :checked="motionEnabled" />
  </Flex>
  <Flex justify="between" align="center" wrap="true">
    <h3 class="m-0">Edit the data</h3>
    <Flex wrap="true">
      <Btn variant="outline" @click="clear">Empty data</Btn>
      <Btn tone="accent" variant="surface" @click="reset">Reset playground</Btn>
    </Flex>
  </Flex>
  <Grid columns="2" columnsSm="3" columnsLg="5">
    <FormInputField
      r-for="row in rows"
      :id="row.id"
      :label="row.label"
      type="number"
      step="1"
      :model="row.value"
    />
  </Grid>
  <Grid columns="1" columnsMd="3">
    <FormInputField id="bar-min" label="Minimum bound" type="number" placeholder="Automatic" :model="minimum" />
    <FormInputField id="bar-max" label="Maximum bound" type="number" placeholder="Automatic" :model="maximum" />
    <FormInputField id="bar-height" label="Chart height (px)" type="number" min="180" step="20" :model="height" />
  </Grid>
  <p class="m-0 text-muted">Blank bounds use the data range. The scale always includes zero and every value. On narrow screens, scroll the preview horizontally to keep its labels readable.</p>
</Flex>`

const presetValues: Record<BarChartPreset, number[]> = {
  mixed: [24, 38, -12, 46, 32],
  positive: [18, 32, 25, 48, 60],
  negative: [-18, -32, -8, -24, -12],
  zero: [0, 0, 0, 0, 0],
}

function createBarChartRows(preset: BarChartPreset): BarChartEditorRow[] {
  return ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'].map((label, index) => ({
    id: `bar-value-${index}`,
    label,
    value: ref<number | string>(presetValues[preset][index]),
  }))
}

function createBarChartPlayground(): BarChartPlayground {
  const preset = ref<BarChartPreset>('mixed')
  const palette = ref<BarChartPalette>('balance')
  const suffix = ref('')
  const minimum = ref<number | string>('')
  const maximum = ref<number | string>('')
  const height = ref<number | string>(280)
  const valuesVisible = ref(true)
  const axisVisible = ref(true)
  const labelsVisible = ref(true)
  const motionEnabled = ref(true)
  const rows = sref(createBarChartRows('mixed'))
  const accent = getThemePaletteVar('semanticTone.accent.button.rest.bgcolor')
  const danger = getThemePaletteVar('semanticTone.danger.button.rest.bgcolor')
  const chartItems = computed<BarChartItem[]>(() =>
    rows().map((row) => {
      const value = Number(row.value())
      return {
        label: row.label,
        value,
        color:
          palette() === 'categorical'
            ? undefined
            : palette() === 'balance' && value < 0
              ? danger
              : accent,
      }
    }),
  )
  return {
    rows,
    chartItems,
    summary: computed(() => {
      if (!rows().length)
        return 'No data. Choose a dataset or reset the playground.'
      const values = rows().map(
        (row) => `${row.label}: ${Number(row.value())}${suffix()}`,
      )
      return values.join(' · ')
    }),
    preset,
    palette,
    suffix,
    minimum,
    maximum,
    height,
    valuesVisible,
    axisVisible,
    labelsVisible,
    motionEnabled,
    presets: [
      { label: 'Mixed gains and losses', value: 'mixed' },
      { label: 'All positive', value: 'positive' },
      { label: 'All negative', value: 'negative' },
      { label: 'All zero', value: 'zero' },
    ],
    palettes: [
      { label: 'Gains / losses', value: 'balance' },
      { label: 'One color per category', value: 'categorical' },
      { label: 'Single accent', value: 'accent' },
    ],
    units: [
      { label: 'No suffix', value: '' },
      { label: 'Percent (%)', value: '%' },
      { label: 'Milliseconds (ms)', value: 'ms' },
      { label: 'Credits (cr)', value: 'cr' },
    ],
    applyPreset: (event) => {
      const next = (event.target as HTMLSelectElement).value as BarChartPreset
      rows(createBarChartRows(next))
    },
    clear: () => rows([]),
    reset: () =>
      batch(() => {
        preset('mixed')
        palette('balance')
        suffix('')
        minimum('')
        maximum('')
        height(280)
        valuesVisible(true)
        axisVisible(true)
        labelsVisible(true)
        motionEnabled(true)
        rows(createBarChartRows('mixed'))
      }),
  }
}

const barChartPlayground = defineComponent<BarChartPlayground>(
  barChartPlaygroundTemplate,
  { context: createBarChartPlayground },
)

const icons: Record<string, string> = {
  'lucide:chevron-down': lucide_chevron_down,
}

createApp(
  {
    components: {
      BarChartPlayground: barChartPlayground,
      ...defineBarChartComponents(),
      ...defineButtonComponents(),
      ...defineFlexComponents(),
      ...defineFormComponents(),
      ...defineFormInputField(),
      ...defineFormSelectField(),
      ...defineGridComponents(),
      ...defineIconComponents((name) => icons[name] ?? ''),
      ...definePanelComponents(),
    },
  },
  { selector: 'app#bar-chart-demo', template: html`<BarChartPlayground />` },
)

A fixed comparison scale

Use explicit bounds to compare values against a stable range. Here, both categories use a zero-to-100 scale. Bounds expand when needed to include the data; they do not clip outlying values.

Example completion 100% 75% 50% 25% 0% Content: 80% Review: 55% 80%55% ContentReview
<BarChart
  title="Example completion"
  ariaLabel="Completion: content 80 percent, review 55 percent"
  :items="[{label:'Content',value:80},{label:'Review',value:55}]"
  minValue="0"
  maxValue="100"
  height="280"
  valueSuffix="%"
  :animated="false"
/>

Positive and negative values

Every bar starts at zero. Losses extend downward, with their values below the bar and category names in a separate row. A symmetric scale makes the direction and magnitude easy to compare.

Change since the previous release 40% 20% 0% -20% -40% API: 24% Jobs: -32% Sync: 18% Cache: -8% 24%-32%18%-8% APIJobsSyncCache
<BarChart
  title="Change since the previous release"
  ariaLabel="Change: API plus 24 percent, jobs minus 32 percent, sync plus 18 percent, cache minus 8 percent"
  :items="[{label:'API',value:24},{label:'Jobs',value:-32},{label:'Sync',value:18},{label:'Cache',value:-8}]"
  minValue="-40"
  maxValue="40"
  height="280"
  valueSuffix="%"
  :animated="false"
/>

A compact trend

Hide the grid and numeric labels when the shape is the useful signal. Keep an accessible description with the exact values. The same component fits a small dashboard card.

76 deployments this week

Daily activity across the release pipeline.

Daily deployments Mon: 8 Tue: 13 Wed: 11 Thu: 19 Fri: 16 Sat: 6 Sun: 3 MonTueWedThuFriSatSun
<Panel tone="accent" variant="surface" bodyClass="p-3">
  <h3 class="mt-0">76 deployments this week</h3>
  <p>Daily activity across the release pipeline.</p>
  <BarChart
    title="Daily deployments"
    ariaLabel="Deployments: Monday 8, Tuesday 13, Wednesday 11, Thursday 19, Friday 16, Saturday 6, Sunday 3"
    :items="[{label:'Mon',value:8},{label:'Tue',value:13},{label:'Wed',value:11},{label:'Thu',value:19},{label:'Fri',value:16},{label:'Sat',value:6},{label:'Sun',value:3}]"
    height="180"
    :showAxis="false"
    :showValues="false"
    :animated="false"
  />
</Panel>

Behavior and accessibility

Each valid item is a category. Non-finite values are removed; negative values extend below zero. SVG title and description help describe the chart, but include nearby text or a data table for exact values. Turn animation off when comparing rapid updates.

API reference

BarChart contract

Props below are the public template API. RefOrValue accepts a literal or a reactive ref; bind refs with a colon-prefixed attribute. Native attributes and events can be passed through the component root.

items

RefOrValue<Array<RefOrValue<BarChartItem>>>
Default
[]

Array of BarChartItem objects with optional label, numeric value (or numeric string), and CSS color. Missing labels become Item 1, Item 2 and so on.

title

RefOrValue<string>
Default
not set

SVG title text; not a separate visible heading.

description

RefOrValue<string>
Default
not set

SVG desc text explaining the data or takeaway.

ariaLabel

RefOrValue<string>
Default
title, then Bar chart

Accessible name on the SVG image.

emptyLabel

RefOrValue<string>
Default
No data

Visible fallback when no usable data remains.

valueSuffix

RefOrValue<string>
Default
not set

Unit appended to formatted values, such as ms or %.

width

RefOrValue<number | string>
Default
100%

CSS width. Numeric values become pixel lengths; strings can use CSS units.

height

RefOrValue<number | string>
Default
automatic

CSS chart height; omitted height preserves the SVG’s intrinsic aspect ratio.

minValue

RefOrValue<number | string>
Default
automatic

Requested lower domain bound. The resolved minimum also includes zero and the smallest data value. Omit it to use the data range automatically.

maxValue

RefOrValue<number | string>
Default
automatic

Requested upper domain bound. The resolved maximum also includes zero and the largest data value, so bars are never clipped by a tighter bound.

animated

RefOrValue<boolean | string>
Default
true

Enables the SVG entrance animation when bars mount. Bind false for immediate rendering, especially while editing values. This is not an interpolated transition between datasets.

showValues

RefOrValue<boolean | string>
Default
true

Draws numeric values beside marks. Enable only when labels have enough room.

showLabels

RefOrValue<boolean | string>
Default
true

Shows category labels along the horizontal axis.

showAxis

RefOrValue<boolean | string>
Default
true

Shows the axis grid and value labels.

tone

RefOrValue<SemanticTone>
Default
inherited

Semantic intent: neutral, accent, secondary, info, success, warning, danger, feature, custom or ghost. The active skin supplies the colors.

variant

RefOrValue<ComponentVariant>
Default
none

Visual treatment. Accepts solid, surface, surfaceAlt, spotlight, glass, flat, flatAlt, flatSolid, outlineFill, outline, subtle, subtleBtn, link, sheen, underline, rail, bracket or none.

variantMode

RefOrValue<ComponentVariantMode>
Default
stateless

Use stateless for content surfaces; stateful enables the treatment’s hover, focus and active styles. It does not add interaction handlers.

Composition

No slots. Register defineBarChartComponents. Bind a ref of BarChartItem[] to update the data.