Bar Chart

Build grouped bar charts with Typlog theme tokens.
alpha

Bar charts are best for discrete comparisons. Use grouped bars to compare peers, a single series to rank categories, and stacked bars when both the total and its composition matter.

Grouped bars

Pass one y-accessor per series to VisGroupedBar. Keep the same key order in ChartRoot.config so the bars, tooltip rows, and legend use identical colors.

This year
Last year
vue
<script setup lang="ts">
import { VisAxis, VisGroupedBar, VisXYContainer } from '@unovis/vue'
import { ChartLegend, ChartRoot, ChartTooltip, ChartCrosshair } from '@typlog/ui/charts'

interface BarPoint {
  month: string
  thisYear: number
  lastYear: number
}

const barData: BarPoint[] = [
  { month: 'Jan', thisYear: 120, lastYear: 92 },
  { month: 'Feb', thisYear: 152, lastYear: 110 },
  { month: 'Mar', thisYear: 180, lastYear: 128 },
  { month: 'Apr', thisYear: 220, lastYear: 160 },
]
const barConfig = {
  thisYear: { label: 'This year' },
  lastYear: { label: 'Last year' },
}
const barX = (_d: BarPoint, index: number) => index
const barY = [(d: BarPoint) => d.thisYear, (d: BarPoint) => d.lastYear]
const xTickFormat = (value: number) => barData[value]?.month ?? ''
</script>

<template>
  <ChartRoot v-slot="{ mounted }" :config="barConfig">
    <VisXYContainer v-if="mounted" :data="barData" :height="220">
      <VisGroupedBar :x="barX" :y="barY" bar-padding="0.1" />
      <VisAxis
        type="x"
        :tick-format="xTickFormat"
        :tick-values="barData.map((_item, index) => index)"
        :tick-line="false"
        :domain-line="false"
        :grid-line="false"
      />
      <VisAxis
        type="y"
        :tick-format="() => ''"
        :tick-line="false"
        :domain-line="false"
        :grid-line="true"
      />
      <ChartTooltip />
      <ChartCrosshair />
    </VisXYContainer>
    <ChartLegend class="mt-3" />
  </ChartRoot>
</template>

Single series

Use one accessor when each category has one value. A local series color and rounded corners can provide emphasis without adding a legend that repeats the axis labels.

vue
<script setup lang="ts">
import { VisAxis, VisGroupedBar, VisXYContainer } from '@unovis/vue'
import {
  ChartCrosshair,
  ChartRoot,
  ChartTooltip,
  type ChartConfig,
} from '@typlog/ui/charts'

interface Point {
  channel: string
  conversions: number
}

const data: Point[] = [
  { channel: 'Direct', conversions: 182 },
  { channel: 'Search', conversions: 246 },
  { channel: 'Social', conversions: 138 },
  { channel: 'Email', conversions: 204 },
]
const config = {
  channel: { label: 'Channel', role: 'x' },
  conversions: { label: 'Conversions' },
} satisfies ChartConfig
const x = (_item: Point, index: number) => index
const y = (item: Point) => item.conversions
const xTickFormat = (value: number) => data[value]?.channel ?? ''
</script>

<template>
  <ChartRoot v-slot="{ mounted }" :config="config">
    <VisXYContainer v-if="mounted" :data="data" :height="220">
      <VisGroupedBar :x="x" :y="y" :rounded-corners="6" />
      <VisAxis
        type="x"
        :tick-format="xTickFormat"
        :tick-values="data.map((_item, index) => index)"
        :tick-line="false"
        :domain-line="false"
        :grid-line="false"
      />
      <VisAxis type="y" :tick-format="() => ''" :tick-line="false" :domain-line="false" />
      <ChartTooltip />
      <ChartCrosshair />
    </VisXYContainer>
  </ChartRoot>
</template>

Stacked bars

Use VisStackedBar for part-to-whole comparisons across several categories. Keep the legend visible because the segment names are not carried by the axis.

Organic
Paid
Referral
vue
<script setup lang="ts">
import { StackedBar } from '@unovis/ts'
import { VisAxis, VisStackedBar, VisXYContainer } from '@unovis/vue'
import {
  ChartCrosshair,
  ChartLegend,
  ChartRoot,
  ChartTooltip,
  type ChartConfig,
} from '@typlog/ui/charts'

interface Point {
  month: string
  organic: number
  paid: number
  referral: number
}

const data: Point[] = [
  { month: 'Jan', organic: 84, paid: 48, referral: 24 },
  { month: 'Feb', organic: 96, paid: 56, referral: 31 },
  { month: 'Mar', organic: 112, paid: 62, referral: 38 },
  { month: 'Apr', organic: 128, paid: 74, referral: 42 },
]
const config = {
  month: { label: 'Month', role: 'x' },
  organic: { label: 'Organic' },
  paid: { label: 'Paid' },
  referral: { label: 'Referral' },
} satisfies ChartConfig
const x = (_item: Point, index: number) => index
const y = [
  (item: Point) => item.organic,
  (item: Point) => item.paid,
  (item: Point) => item.referral,
]
const xTickFormat = (value: number) => data[value]?.month ?? ''
</script>

<template>
  <ChartRoot v-slot="{ mounted }" :config="config">
    <VisXYContainer v-if="mounted" :data="data" :height="240">
      <VisStackedBar
        :x="x"
        :y="y"
        :rounded-corners="5"
      />
      <VisAxis
        type="x"
        :tick-format="xTickFormat"
        :tick-values="data.map((_item, index) => index)"
        :tick-line="false"
        :domain-line="false"
        :grid-line="false"
      />
      <VisAxis type="y" :tick-format="() => ''" :tick-line="false" :domain-line="false" />
      <ChartTooltip :targets="[StackedBar]" />
      <ChartCrosshair />
    </VisXYContainer>
    <ChartLegend class="mt-3" align="center" />
  </ChartRoot>
</template>

Start the value axis at zero unless the chart explicitly communicates a bounded deviation. Truncated bar axes exaggerate small differences in length.

Horizontal bars

Set orientation to Orientation.Horizontal when category labels need more room or when ranking is easier to scan from top to bottom. The data accessor stays on x and the value accessor stays on y; only their rendered axes change, so category ticks belong to the y-axis and numeric values belong to the x-axis.

Use Direction.South on the container to keep the first data item at the top. Because the standard crosshair follows the horizontal x-domain, attach ChartTooltip directly to GroupedBar.selectors.bar for horizontal bars.

vue
<script setup lang="ts">
import { Direction, GroupedBar, Orientation } from '@unovis/ts'
import { VisAxis, VisGroupedBar, VisXYContainer } from '@unovis/vue'
import {
  ChartRoot,
  ChartTooltip,
  type ChartConfig,
} from '@typlog/ui/charts'

interface Point {
  channel: string
  conversions: number
}

const data: Point[] = [
  { channel: 'Direct', conversions: 182 },
  { channel: 'Search', conversions: 246 },
  { channel: 'Social', conversions: 138 },
  { channel: 'Email', conversions: 204 },
]
const config = {
  channel: { label: 'Channel', role: 'x' },
  conversions: { label: 'Conversions', color: 'var(--blue-9)' },
} satisfies ChartConfig
const x = (_item: Point, index: number) => index
const y = (item: Point) => item.conversions
const yTickFormat = (value: number) => data[value]?.channel ?? ''
</script>

<template>
  <ChartRoot v-slot="{ mounted }" :config="config">
    <VisXYContainer
      v-if="mounted"
      :data="data"
      :height="260"
      :y-direction="Direction.South"
    >
      <VisGroupedBar
        :x="x"
        :y="y"
        :rounded-corners="6"
        :bar-padding="0.15"
        :orientation="Orientation.Horizontal"
      />
      <VisAxis
        type="x"
        :tick-line="false"
        :domain-line="false"
        :grid-line="true"
      />
      <VisAxis
        type="y"
        :tick-format="yTickFormat"
        :tick-values="data.map((_item, index) => index)"
        :tick-line="false"
        :domain-line="false"
        :grid-line="false"
      />
      <ChartTooltip :targets="[GroupedBar]" />
    </VisXYContainer>
  </ChartRoot>
</template>
Last updated Sep 6, 2026