Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions docs/content/2.guides/05.chart-size.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ navigation:
icon: i-lucide-maximize
---

Every chart in vccs requires a width and height. You can set these explicitly or use `<ResponsiveContainer>` for dynamic sizing.
Every chart in vccs needs a width and height. You can set them explicitly, let the chart size itself with the `responsive` prop, or wrap it in `<ResponsiveContainer>`.

## Explicit dimensions

Expand All @@ -17,9 +17,34 @@ Pass `width` and `height` directly to the chart component:
</BarChart>
```

## Responsive sizing
## Responsive sizing with the `responsive` prop

Wrap your chart in `<ResponsiveContainer>` to fill its parent container. All the chart demos on this site use `ResponsiveContainer` — here's an example:
The simplest way to make a chart follow its parent is the `responsive` prop (recommended). No extra wrapper component is needed — the chart's own root element fills the parent via standard CSS sizing and measures itself with a `ResizeObserver`:

```vue
<!-- Full width, fixed height -->
<LineChart responsive :style="{ height: '300px' }" :data="data">
<Line data-key="value" fill="#f97316" />
</LineChart>

<!-- Fill a sized parent (flex/grid cell, etc.) -->
<div style="width: 100%; height: 300px">
<LineChart responsive :data="data">
<Line data-key="value" />
</LineChart>
</div>

<!-- Keep an aspect ratio via CSS -->
<LineChart responsive :style="{ width: '100%', aspectRatio: '16 / 9' }" :data="data">
<Line data-key="value" />
</LineChart>
```

When `responsive` is set, any `width`/`height` props are ignored — sizing is driven entirely by CSS. Give the parent (or the chart's `style`) a definite height, otherwise the chart collapses to zero height and nothing renders.

## Responsive sizing with `ResponsiveContainer`

Alternatively, wrap your chart in `<ResponsiveContainer>` to fill its parent container. All the chart demos on this site use `ResponsiveContainer` — here's an example:

::chart-demo{name="Responsive Chart" description="A chart using ResponsiveContainer to fill its parent width." src="bar-charts/simple-bar-chart"}
::
Expand Down Expand Up @@ -61,11 +86,11 @@ Increase margins when you need space for:

## Common sizing issues

**Chart not visible**: Ensure the parent element has a defined width and height. `ResponsiveContainer` with `width="100%"` requires its parent to have a non-zero width.
**Chart not visible**: Ensure the parent element has a defined width and height. Both the `responsive` prop and `ResponsiveContainer` require the parent to have a non-zero size (a definite height in particular).

**Chart too small**: Check that `margin` values aren't consuming most of the available space. Large margins on a small chart can leave very little room for data.

**SSR rendering**: `ResponsiveContainer` relies on `ResizeObserver` and renders nothing on the server. Wrap it in `<ClientOnly>` in Nuxt/SSR environments:
**SSR rendering**: Both the `responsive` prop and `ResponsiveContainer` rely on `ResizeObserver`, so the chart only sizes itself on the client. In Nuxt/SSR environments, wrap the chart in `<ClientOnly>`:

```vue
<ClientOnly>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type PropType, type Ref, type StyleValue, type VNode, defineComponent, ref } from 'vue'
import { type PropType, type StyleValue, defineComponent, onMounted, onUnmounted, ref } from 'vue'
import { mouseLeaveChart } from '../state/tooltipSlice'
import { useAppDispatch } from '../state/hooks'
import { mouseClickAction, mouseMoveAction } from '../state/mouseEventsMiddleware'
Expand All @@ -13,8 +13,8 @@ import { providePortalRaw } from '@/chart/TooltipPortalContext'
import { provideLegendPortalRaw } from '@/chart/LegendPortalContext'
import { getChartPointer } from '@/utils/chart'

export const RechartsWrapper = defineComponent({
name: 'RechartsWrapper',
export const ChartsWrapper = defineComponent({
name: 'ChartsWrapper',
props: {
class: classProp,
height: { type: Number, required: true },
Expand All @@ -26,9 +26,11 @@ export const RechartsWrapper = defineComponent({
onMouseLeave: { type: Function as PropType<CategoricalChartFunc> },
onMouseMove: { type: Function as PropType<CategoricalChartFunc> },
onMouseUp: { type: Function as PropType<CategoricalChartFunc> },
onResize: { type: Function as PropType<(width: number, height: number) => void> },
onTouchEnd: { type: Function as PropType<CategoricalChartFunc> },
onTouchMove: { type: Function as PropType<CategoricalChartFunc> },
onTouchStart: { type: Function as PropType<CategoricalChartFunc> },
responsive: { type: Boolean, default: false },
style: { type: [String, Object, Array] as PropType<StyleValue> },
width: { type: Number, required: true },
},
Expand All @@ -42,12 +44,33 @@ export const RechartsWrapper = defineComponent({
const legendPortal = ref<HTMLElement | null>(null)
providePortalRaw(tooltipPortal)
provideLegendPortalRaw(legendPortal)
const wrapperEl = ref<HTMLDivElement | null>(null)
const innerRef = (node: HTMLDivElement | null) => {
scaleRef.value = node
tooltipPortal.value = node
legendPortal.value = node
wrapperEl.value = node
}

// When `responsive` is set, the wrapper div fills its parent via CSS and
// measures itself, feeding the pixel size back to the chart (Recharts 3.3+ pattern).
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
if (!props.responsive || !wrapperEl.value) {
return
}
const { width, height } = wrapperEl.value.getBoundingClientRect()
props.onResize?.(width, height)
resizeObserver = new ResizeObserver((entries) => {
const { width: w, height: h } = entries[0].contentRect
props.onResize?.(w, h)
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does ResizeObserver.observe support a {box: 'border-box'} option to get border-box measurements directly?

💡 Result:

Yes, the ResizeObserver.observe method supports the box option, which allows you to specify whether the observer should track changes to the element's content-box (the default) or its border-box [1][2][3]. You can use it by passing an options object as the second argument to the observe method [2][3]: resizeObserver.observe(element, { box: 'border-box' }); When this option is set, the observer monitors the size of the element's border box as defined in CSS [1][3]. This option specifically determines which box the observer monitors for layout changes [3]. Note that the ResizeObserver API also provides additional options such as 'device-pixel-content-box' for tracking sizes in device pixels [2][4][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the target file with line numbers
sed -n '1,220p' packages/vue/src/chart/ChartsWrapper.tsx | cat -n

Repository: unovue/vue-charts

Length of output: 7935


Align the resize observer with the initial measurement. getBoundingClientRect() returns the border box, but ResizeObserver defaults to contentRect; if props.style adds padding or a border, the first onResize value will differ from later updates. Use resizeObserver.observe(wrapperEl.value, { box: 'border-box' }) or make both paths measure the same box.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/vue/src/chart/ChartsWrapper.tsx` around lines 62 - 67, The initial
measurement in ChartsWrapper.tsx is using getBoundingClientRect() while the
ResizeObserver callback reads contentRect, so the first onResize value can
differ from later updates when padding or borders are present. Update the resize
logic in the same flow that creates resizeObserver so both measurements use the
same box model, preferably by observing wrapperEl.value with box: 'border-box'
or by changing the initial read to match contentRect.

resizeObserver.observe(wrapperEl.value)
})
onUnmounted(() => {
resizeObserver?.disconnect()
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

responsive prop is only checked once at mount, not reactive.

The ResizeObserver setup/teardown is gated by props.responsive inside onMounted's closure, which runs a single time. If a consumer toggles responsive after the component mounts, the inline style will correctly switch (it's computed in the render function), but the observer will never start (if it was initially false) or never stop (if it was initially true), leaving the chart's measured size permanently stale. Guidelines recommend mapping effects with reactive dependencies to watch, not a one-shot lifecycle hook.

♻️ Suggested fix using `watch`
-    let resizeObserver: ResizeObserver | null = null
-    onMounted(() => {
-      if (!props.responsive || !wrapperEl.value) {
-        return
-      }
-      const { width, height } = wrapperEl.value.getBoundingClientRect()
-      props.onResize?.(width, height)
-      resizeObserver = new ResizeObserver((entries) => {
-        const { width: w, height: h } = entries[0].contentRect
-        props.onResize?.(w, h)
-      })
-      resizeObserver.observe(wrapperEl.value)
-    })
-    onUnmounted(() => {
-      resizeObserver?.disconnect()
-    })
+    let resizeObserver: ResizeObserver | null = null
+    function startObserving() {
+      if (!wrapperEl.value) return
+      const { width, height } = wrapperEl.value.getBoundingClientRect()
+      props.onResize?.(width, height)
+      resizeObserver = new ResizeObserver((entries) => {
+        const { width: w, height: h } = entries[0].contentRect
+        props.onResize?.(w, h)
+      })
+      resizeObserver.observe(wrapperEl.value)
+    }
+    function stopObserving() {
+      resizeObserver?.disconnect()
+      resizeObserver = null
+    }
+    watch(() => props.responsive, (responsive) => {
+      stopObserving()
+      if (responsive) startObserving()
+    }, { immediate: true })
+    onUnmounted(stopObserving)

As per coding guidelines, packages/vue/src/**/*.{ts,tsx} should "map React useState/useEffect to Vue ref/watch" when porting Recharts patterns; a prop-dependent effect like this should be reactive.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/vue/src/chart/ChartsWrapper.tsx` around lines 58 - 73, The
ResizeObserver setup in ChartsWrapper is tied to a one-time onMounted check, so
changes to props.responsive after mount are ignored. Move the observer lifecycle
logic into a watch on props.responsive (and wrapperEl if needed) so it starts
when responsive becomes true and disconnects when it becomes false, keeping the
existing onMounted/onUnmounted cleanup behavior aligned with the reactive prop.

Source: Coding guidelines

const myOnClick = (e: MouseEvent) => {
// Capture chart pointer synchronously before dispatching to middleware,
// because createListenerMiddleware defers to microtask and e.currentTarget will be null by then
Expand Down Expand Up @@ -119,7 +142,12 @@ export const RechartsWrapper = defineComponent({
return () => (
<div
class={['v-charts-wrapper', props.class]}
style={[{ position: 'relative', cursor: 'default', width: `${props.width}px`, height: `${props.height}px` }, props.style]}
style={[
props.responsive
? { position: 'relative', cursor: 'default', width: '100%', height: '100%' }
: { position: 'relative', cursor: 'default', width: `${props.width}px`, height: `${props.height}px` },
props.style,
]}
role="application"
onClick={myOnClick}
onContextmenu={myOnContextMenu}
Expand Down
6 changes: 3 additions & 3 deletions packages/vue/src/chart/Sankey.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { provideStore } from '@reduxjs/vue-redux'
import { Animate } from '@/animation/Animate'
import { Layer } from '@/container/Layer'
import Surface from '@/container/Surface'
import { RechartsWrapper } from './RechartsWrapper'
import { ChartsWrapper } from './ChartsWrapper'
import { createRechartsStore } from '@/state/store'
import { useAppDispatch } from '@/state/hooks'
import {
Expand Down Expand Up @@ -379,12 +379,12 @@ const _Sankey = defineComponent({
return null

return (
<RechartsWrapper width={props.width} height={props.height}>
<ChartsWrapper width={props.width} height={props.height}>
<SankeyInner {...props}>
{{ node: slots.node, link: slots.link }}
</SankeyInner>
{slots.default?.()}
</RechartsWrapper>
</ChartsWrapper>
)
}
},
Expand Down
6 changes: 3 additions & 3 deletions packages/vue/src/chart/SunburstChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Layer } from '@/container/Layer'
import Surface from '@/container/Surface'
import { Sector } from '@/shape/Sector'
import { polarToCartesian } from '@/utils/polar'
import { RechartsWrapper } from './RechartsWrapper'
import { ChartsWrapper } from './ChartsWrapper'
import { createRechartsStore } from '@/state/store'
import { useAppDispatch } from '@/state/hooks'
import {
Expand Down Expand Up @@ -225,15 +225,15 @@ export const SunburstChart = defineComponent({
if (!props.data?.children || props.data.children.length === 0) return null

return (
<RechartsWrapper
<ChartsWrapper
width={props.width}
height={props.height}
>
<SunburstInner {...props}>
{{ content: slots.content }}
</SunburstInner>
{slots.default?.()}
</RechartsWrapper>
</ChartsWrapper>
)
}
},
Expand Down
6 changes: 3 additions & 3 deletions packages/vue/src/chart/Treemap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Animate } from '@/animation/Animate'
import { Layer } from '@/container/Layer'
import Surface from '@/container/Surface'
import { getStringSize } from '@/utils/attrs'
import { RechartsWrapper } from './RechartsWrapper'
import { ChartsWrapper } from './ChartsWrapper'
import { createRechartsStore } from '@/state/store'
import { useAppDispatch } from '@/state/hooks'
import { setActiveMouseOverItemIndex, setActiveClickItemIndex, mouseLeaveItem, addTooltipEntrySettings, removeTooltipEntrySettings } from '@/state/tooltipSlice'
Expand Down Expand Up @@ -442,15 +442,15 @@ export const Treemap = defineComponent({
if (!props.data || props.data.length === 0) return null

return (
<RechartsWrapper
<ChartsWrapper
width={props.width}
height={props.height}
>
<TreemapInner {...props}>
{{ content: slots.content }}
</TreemapInner>
{slots.default?.()}
</RechartsWrapper>
</ChartsWrapper>
)
}
},
Expand Down
41 changes: 41 additions & 0 deletions packages/vue/src/chart/__stories__/ResponsiveProp.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { StoryObj } from '@storybook/vue3-vite'
import { LineChart } from '@/chart/LineChart'
import { Line } from '@/cartesian/line'
import { XAxis, YAxis } from '@/cartesian/axis'
import { CartesianGrid } from '@/cartesian/cartesian-grid'
import { Tooltip } from '@/components/Tooltip'

export default {
title: 'Examples/ResponsiveProp',
component: LineChart,
}

const data = [
{ name: 'Page A', uv: 4000 },
{ name: 'Page B', uv: 3000 },
{ name: 'Page C', uv: 2000 },
{ name: 'Page D', uv: 2780 },
{ name: 'Page E', uv: 1890 },
{ name: 'Page F', uv: 2390 },
{ name: 'Page G', uv: 3490 },
]

/**
* The `responsive` prop makes the chart fill its parent via CSS and measure itself
* with a ResizeObserver — no `ResponsiveContainer` wrapper needed.
*/
export const Responsive: StoryObj = {
render: () => {
return (
<div style={{ width: '100%', height: '300px' }}>
<LineChart responsive data={[...data]}>
<CartesianGrid stroke-dasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="uv" stroke="#f97316" activeDot={{ r: 8 }} />
</LineChart>
</div>
)
},
}
2 changes: 1 addition & 1 deletion packages/vue/src/chart/__tests__/Treemap.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ describe('tooltip integration', () => {
</Treemap>
))

// RechartsWrapper should be rendered
// ChartsWrapper should be rendered
const wrapper = container.querySelector('.v-charts-wrapper')
expect(wrapper).toBeTruthy()

Expand Down
113 changes: 113 additions & 0 deletions packages/vue/src/chart/__tests__/responsive-prop.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { render } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { Line, LineChart } from '@/index'
import { mockGetBoundingClientRect } from '@/test/mockGetBoundingClientRect'

class MockResizeObserver {
callback: ResizeObserverCallback
static instances: MockResizeObserver[] = []

constructor(callback: ResizeObserverCallback) {
this.callback = callback
MockResizeObserver.instances.push(this)
}

observe() {}
unobserve() {}
disconnect() {}

trigger(width: number, height: number) {
this.callback(
[{ contentRect: { width, height } } as ResizeObserverEntry],
this as unknown as ResizeObserver,
)
}
}

const data = [
{ name: 'A', uv: 400 },
{ name: 'B', uv: 300 },
{ name: 'C', uv: 200 },
]

describe('responsive prop', () => {
beforeEach(() => {
mockGetBoundingClientRect({ width: 500, height: 300 })
MockResizeObserver.instances = []
vi.stubGlobal('ResizeObserver', MockResizeObserver)
})

it('renders the wrapper div with 100% CSS sizing in responsive mode', async () => {
const { container } = render(() => (
<LineChart responsive data={data}>
<Line dataKey="uv" isAnimationActive={false} />
</LineChart>
))
await nextTick()

const wrapper = container.querySelector('.v-charts-wrapper') as HTMLElement
expect(wrapper).toBeTruthy()
expect(wrapper.style.width).toBe('100%')
expect(wrapper.style.height).toBe('100%')
})

it('does not render the chart surface until the wrapper is measured', () => {
// Initial measurement of 0x0 keeps the chart gated out.
mockGetBoundingClientRect({ width: 0, height: 0 })

const { container } = render(() => (
<LineChart responsive data={data}>
<Line dataKey="uv" isAnimationActive={false} />
</LineChart>
))

expect(container.querySelector('.v-charts-wrapper')).toBeTruthy()
expect(container.querySelector('.vcharts-surface')).toBeNull()
})

it('renders the chart at the measured size once mounted', async () => {
const { container } = render(() => (
<LineChart responsive data={data}>
<Line dataKey="uv" isAnimationActive={false} />
</LineChart>
))
await nextTick()

const svg = container.querySelector('.vcharts-surface') as SVGElement
expect(svg).toBeTruthy()
expect(svg.getAttribute('width')).toBe('500')
expect(svg.getAttribute('height')).toBe('300')
})

it('updates the chart size when the ResizeObserver reports a new size', async () => {
const { container } = render(() => (
<LineChart responsive data={data}>
<Line dataKey="uv" isAnimationActive={false} />
</LineChart>
))
await nextTick()

expect(MockResizeObserver.instances.length).toBe(1)
MockResizeObserver.instances[0].trigger(640, 480)
await nextTick()

const svg = container.querySelector('.vcharts-surface') as SVGElement
expect(svg.getAttribute('width')).toBe('640')
expect(svg.getAttribute('height')).toBe('480')
})

it('renders at fixed px size and creates no ResizeObserver when responsive is not set', async () => {
const { container } = render(() => (
<LineChart width={400} height={320} data={data}>
<Line dataKey="uv" isAnimationActive={false} />
</LineChart>
))
await nextTick()

const wrapper = container.querySelector('.v-charts-wrapper') as HTMLElement
expect(wrapper.style.width).toBe('400px')
expect(wrapper.style.height).toBe('320px')
expect(MockResizeObserver.instances.length).toBe(0)
})
})
Loading
Loading