Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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,5 @@
import { type PropType, type Ref, type StyleValue, type VNode, defineComponent, ref } from 'vue'
import { type PropType, type StyleValue, defineComponent, onMounted, ref } from 'vue'
import { useResizeObserver } from '@vueuse/core'
import { mouseLeaveChart } from '../state/tooltipSlice'
import { useAppDispatch } from '../state/hooks'
import { mouseClickAction, mouseMoveAction } from '../state/mouseEventsMiddleware'
Expand All @@ -13,8 +14,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 +27,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,10 +45,30 @@ 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).
// `useResizeObserver` owns the observer lifecycle (auto-disconnects on unmount);
// the initial getBoundingClientRect avoids a first-frame flash before it fires.
if (props.responsive) {
useResizeObserver(wrapperEl, (entries) => {
const { width, height } = entries[0].contentRect
props.onResize?.(width, height)
})
onMounted(() => {
if (!wrapperEl.value) {
return
}
const { width, height } = wrapperEl.value.getBoundingClientRect()
props.onResize?.(width, height)
})
}

const myOnClick = (e: MouseEvent) => {
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
114 changes: 114 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,114 @@
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).toBeGreaterThanOrEqual(1)
// The latest instance is the one actively observing the wrapper.
MockResizeObserver.instances.at(-1)!.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