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 15, 2026