# Line Chart

> A customizable line chart component with animations, interactions, and gradient fills.

**BNA UI** — a React Native / Expo component library.
These components render through `react-native`, not the DOM: there are no HTML
elements, no Tailwind classes and no Radix primitives. Source is copied into your
project and imported through `@/components/ui/*`, `@/components/charts/*`,
`@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than
hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`)
come from `@/theme/globals`.

- Docs: https://ui.ahmedbna.com/docs/charts/line-chart
- Markdown: https://ui.ahmedbna.com/docs/charts/line-chart.md
- Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/line-chart.json
- Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/line-chart.json
- Install: `npx bna-ui add line-chart`
- npm dependencies: `react-native-gesture-handler`, `react-native-reanimated`, `react-native-svg`, `react-native-worklets`
- Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`
- Preview recording: https://demo.ahmedbna.com/0359-line-chart-demo.MOV

---

**Example:** A basic line chart with smooth animations and grid lines

```tsx
// components/demo/charts/line-chart/line-chart-demo.tsx
import { ChartContainer } from '@/components/charts/chart-container';
import { LineChart } from '@/components/charts/line-chart';
import React from 'react';

const sampleData = [
  { x: 1, y: 10, label: 'Jan' },
  { x: 2, y: 25, label: 'Feb' },
  { x: 3, y: 15, label: 'Mar' },
  { x: 4, y: 40, label: 'Apr' },
  { x: 5, y: 30, label: 'May' },
  { x: 6, y: 55, label: 'Jun' },
  { x: 7, y: 45, label: 'Jul' },
];

export function LineChartDemo() {
  return (
    <ChartContainer
      title='Revenue Trend'
      description='Monthly revenue growth over time'
    >
      <LineChart
        data={sampleData}
        config={{
          height: 220,
          showGrid: true,
          showLabels: true,
          animated: true,
          duration: 1500,
          interactive: true,
        }}
      />
    </ChartContainer>
  );
}
```

## Installation

### CLI

```bash
npx bna-ui add line-chart
```

### Manual

**1.** Install the required dependencies.

```bash
npm install react-native-svg react-native-reanimated react-native-worklets react-native-gesture-handler
```

**2.** Copy and paste the following code into your project.

```tsx
// components/charts/line-chart.tsx
import { useColor } from '@/hooks/useColor';
import React, { useEffect, useState } from 'react';
import { LayoutChangeEvent, View, ViewStyle } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  runOnJS,
  SharedValue,
  useAnimatedProps,
  useSharedValue,
  withDelay,
  withSpring,
  withTiming,
} from 'react-native-reanimated';
import Svg, {
  Circle,
  Defs,
  G,
  Line,
  LinearGradient,
  Path,
  Stop,
  Text as SvgText,
} from 'react-native-svg';

interface ChartConfig {
  width?: number;
  height?: number;
  padding?: number;
  showGrid?: boolean;
  showLabels?: boolean;
  animated?: boolean;
  duration?: number;
  gradient?: boolean;
  interactive?: boolean;
  showYLabels?: boolean;
  yLabelCount?: number;
  yAxisWidth?: number;
}

export type ChartDataPoint = {
  x: string | number;
  y: number;
  label?: string;
};

// Utility functions
const createPath = (points: { x: number; y: number }[]): string => {
  if (points.length === 0) return '';

  let path = `M${points[0].x},${points[0].y}`;

  for (let i = 1; i < points.length; i++) {
    const prevPoint = points[i - 1];
    const currentPoint = points[i];

    // Create smooth curves using quadratic bezier
    const cpx = (prevPoint.x + currentPoint.x) / 2;
    const cpy = prevPoint.y;

    path += ` Q${cpx},${cpy} ${currentPoint.x},${currentPoint.y}`;
  }

  return path;
};

const createAreaPath = (
  points: { x: number; y: number }[],
  height: number
): string => {
  if (points.length === 0) return '';

  let path = createPath(points);
  const lastPoint = points[points.length - 1];
  const firstPoint = points[0];

  path += ` L${lastPoint.x},${height} L${firstPoint.x},${height} Z`;

  return path;
};

// Helper function to format numbers for display
const formatNumber = (num: number): string => {
  if (num >= 1000000) {
    return (num / 1000000).toFixed(1) + 'M';
  } else if (num >= 1000) {
    return (num / 1000).toFixed(1) + 'K';
  }
  return num.toFixed(0);
};

// Animated SVG Components
const AnimatedPath = Animated.createAnimatedComponent(Path);
const AnimatedCircle = Animated.createAnimatedComponent(Circle);

type AnimatedPointProps = {
  x: number;
  y: number;
  color: string;
  index: number;
  animationProgress: SharedValue<number>;
};

// Per-item hook must live in its own mounted subcomponent, not in the
// parent's .map() body — calling useAnimatedProps/useAnimatedStyle per
// loop iteration violates Rules of Hooks the moment data.length changes.
const AnimatedPoint = React.memo(
  ({ x, y, color, index, animationProgress }: AnimatedPointProps) => {
    // Animate the radius (not a style `scale` transform, which would pivot
    // around the SVG origin rather than the circle's own center) for a
    // staggered spring pop-in per point.
    const pointAnimatedProps = useAnimatedProps(() => ({
      opacity: animationProgress.value,
      r: withDelay(index * 50, withSpring(animationProgress.value * 4)),
    }));

    return (
      <AnimatedCircle
        cx={x}
        cy={y}
        fill={color}
        animatedProps={pointAnimatedProps}
      />
    );
  }
);

type Props = {
  data: ChartDataPoint[];
  config?: ChartConfig;
  style?: ViewStyle;
};

export const LineChart = ({ data, config = {}, style }: Props) => {
  const [containerWidth, setContainerWidth] = useState(300);

  const {
    height = 200,
    padding = 20,
    showGrid = true,
    showLabels = true,
    animated = true,
    duration = 1000,
    gradient = false,
    interactive = false,
    showYLabels = true,
    yLabelCount = 5,
    yAxisWidth = 20,
  } = config;

  // Use measured width or fallback to config width or default
  const chartWidth = containerWidth || config.width || 300;

  const primaryColor = useColor('primary');
  const mutedColor = useColor('mutedForeground');

  const animationProgress = useSharedValue(0);
  const [activePointIndex, setActivePointIndex] = useState<number | null>(null);

  const handleLayout = (event: LayoutChangeEvent) => {
    const { width: measuredWidth } = event.nativeEvent.layout;
    if (measuredWidth > 0) {
      setContainerWidth(measuredWidth);
    }
  };

  useEffect(() => {
    if (animated) {
      animationProgress.value = withTiming(1, { duration });
    } else {
      animationProgress.value = 1;
    }
  }, [data, animated, duration]);

  if (!data.length) return null;

  const maxValue = Math.max(...data.map((d) => d.y));
  const minValue = Math.min(...data.map((d) => d.y));
  const valueRange = maxValue - minValue || 1;

  // Adjust padding to account for y-axis labels
  const leftPadding = showYLabels ? padding + yAxisWidth : padding;
  const innerChartWidth = chartWidth - leftPadding - padding;
  const chartHeight = height - padding * 2;

  // Convert data to screen coordinates. A single-point dataset would divide
  // by zero (data.length - 1 === 0) — center it instead of producing NaN.
  const points = data.map((point, index) => ({
    x:
      leftPadding +
      (data.length > 1 ? index / (data.length - 1) : 0.5) * innerChartWidth,
    y: padding + ((maxValue - point.y) / valueRange) * chartHeight,
  }));

  const pathData = createPath(points);
  const areaPathData = gradient ? createAreaPath(points, height - padding) : '';

  // Generate y-axis labels
  const yAxisLabels = [];
  if (showYLabels) {
    for (let i = 0; i < yLabelCount; i++) {
      const ratio = i / (yLabelCount - 1);
      const value = maxValue - ratio * valueRange;
      const y = padding + ratio * chartHeight;
      yAxisLabels.push({ value, y });
    }
  }

  // Fixed animated props for SVG components
  const areaAnimatedProps = useAnimatedProps(() => ({
    strokeDasharray: animated
      ? `${animationProgress.value * 1000} 1000`
      : undefined,
  }));

  const lineAnimatedProps = useAnimatedProps(() => ({
    strokeDasharray: animated
      ? `${animationProgress.value * 1000} 1000`
      : undefined,
  }));

  const findNearestPointIndex = (x: number): number => {
    let nearest = 0;
    let minDistance = Math.abs(points[0].x - x);
    for (let i = 1; i < points.length; i++) {
      const distance = Math.abs(points[i].x - x);
      if (distance < minDistance) {
        minDistance = distance;
        nearest = i;
      }
    }
    return nearest;
  };

  // Pan gesture using new Gesture API. Disabled (not just no-op'd) when
  // !interactive so it doesn't compete with a parent ScrollView's own pan
  // recognizer for charts that never use it.
  const panGesture = Gesture.Pan()
    .enabled(interactive)
    .onStart((event) => {
      runOnJS(setActivePointIndex)(findNearestPointIndex(event.x));
    })
    .onUpdate((event) => {
      runOnJS(setActivePointIndex)(findNearestPointIndex(event.x));
    })
    .onEnd(() => {
      runOnJS(setActivePointIndex)(null);
    });

  const chartAccessibilityLabel = `Line chart with ${data.length} data points, ranging from ${formatNumber(minValue)} to ${formatNumber(maxValue)}`;

  return (
    <View
      style={[{ width: '100%', height }, style]}
      onLayout={handleLayout}
      accessibilityRole='image'
      accessibilityLabel={chartAccessibilityLabel}
    >
      <GestureDetector gesture={panGesture}>
        <Animated.View>
          <Svg width={chartWidth} height={height}>
            <Defs>
              {gradient && (
                <LinearGradient id='gradient' x1='0%' y1='0%' x2='0%' y2='100%'>
                  <Stop
                    offset='0%'
                    stopColor={primaryColor}
                    stopOpacity='0.3'
                  />
                  <Stop
                    offset='100%'
                    stopColor={primaryColor}
                    stopOpacity='0.05'
                  />
                </LinearGradient>
              )}
            </Defs>

            {/* Y-axis labels */}
            {showYLabels && (
              <G>
                {yAxisLabels.map((label, index) => (
                  <SvgText
                    key={`y-label-${index}`}
                    x={leftPadding - 10}
                    y={label.y + 4}
                    textAnchor='end'
                    fontSize={10}
                    fill={mutedColor}
                  >
                    {formatNumber(label.value)}
                  </SvgText>
                ))}
              </G>
            )}

            {/* Grid lines */}
            {showGrid && (
              <G>
                {/* Horizontal grid lines */}
                {yAxisLabels.map((label, index) => (
                  <Line
                    key={`grid-h-${index}`}
                    x1={leftPadding}
                    y1={label.y}
                    x2={chartWidth - padding}
                    y2={label.y}
                    stroke={mutedColor}
                    strokeWidth={0.5}
                    opacity={0.3}
                  />
                ))}

                {/* Vertical grid lines */}
                {points.map((point, index) => (
                  <Line
                    key={`grid-v-${index}`}
                    x1={point.x}
                    y1={padding}
                    x2={point.x}
                    y2={height - padding}
                    stroke={mutedColor}
                    strokeWidth={0.5}
                    opacity={0.2}
                  />
                ))}
              </G>
            )}

            {/* Area fill */}
            {gradient && (
              <AnimatedPath
                d={areaPathData}
                fill='url(#gradient)'
                animatedProps={areaAnimatedProps}
              />
            )}

            {/* Line path */}
            <AnimatedPath
              d={pathData}
              stroke={primaryColor}
              strokeWidth={2}
              fill='none'
              strokeLinecap='round'
              strokeLinejoin='round'
              animatedProps={lineAnimatedProps}
            />

            {/* Data points */}
            {points.map((point, index) => (
              <AnimatedPoint
                key={`point-${index}`}
                x={point.x}
                y={point.y}
                color={primaryColor}
                index={index}
                animationProgress={animationProgress}
              />
            ))}

            {/* X-axis labels */}
            {showLabels && (
              <G>
                {data.map((point, index) => (
                  <SvgText
                    key={`x-label-${index}`}
                    x={points[index].x}
                    y={height - 5}
                    textAnchor='middle'
                    fontSize={10}
                    fill={mutedColor}
                  >
                    {point.label || point.x.toString()}
                  </SvgText>
                ))}
              </G>
            )}

            {/* Interactive tooltip */}
            {interactive && activePointIndex !== null && (
              <G>
                <Line
                  x1={points[activePointIndex].x}
                  y1={padding}
                  x2={points[activePointIndex].x}
                  y2={height - padding}
                  stroke={mutedColor}
                  strokeWidth={1}
                  strokeDasharray='4 4'
                  opacity={0.5}
                />
                <Circle
                  cx={points[activePointIndex].x}
                  cy={points[activePointIndex].y}
                  r={6}
                  fill={primaryColor}
                  stroke='white'
                  strokeWidth={2}
                />
                <SvgText
                  x={points[activePointIndex].x}
                  y={Math.max(12, points[activePointIndex].y - 12)}
                  textAnchor='middle'
                  fontSize={11}
                  fontWeight='700'
                  fill={mutedColor}
                >
                  {formatNumber(data[activePointIndex].y)}
                </SvgText>
              </G>
            )}
          </Svg>
        </Animated.View>
      </GestureDetector>
    </View>
  );
};
```

**3.** Update the import paths to match your project setup.

## Usage

```tsx
import { LineChart } from '@/components/charts/line-chart';
```

```tsx
const data = [
  { x: 'Jan', y: 100, label: 'January' },
  { x: 'Feb', y: 120, label: 'February' },
  { x: 'Mar', y: 90, label: 'March' },
  { x: 'Apr', y: 140, label: 'April' },
];

<LineChart
  data={data}
  config={{
    height: 200,
    showGrid: true,
    showLabels: true,
    animated: true,
  }}
/>;
```

## Examples

#### Basic Line Chart

**Example:** A basic line chart with smooth animations and grid lines

```tsx
// components/demo/charts/line-chart/line-chart-demo.tsx
import { ChartContainer } from '@/components/charts/chart-container';
import { LineChart } from '@/components/charts/line-chart';
import React from 'react';

const sampleData = [
  { x: 1, y: 10, label: 'Jan' },
  { x: 2, y: 25, label: 'Feb' },
  { x: 3, y: 15, label: 'Mar' },
  { x: 4, y: 40, label: 'Apr' },
  { x: 5, y: 30, label: 'May' },
  { x: 6, y: 55, label: 'Jun' },
  { x: 7, y: 45, label: 'Jul' },
];

export function LineChartDemo() {
  return (
    <ChartContainer
      title='Revenue Trend'
      description='Monthly revenue growth over time'
    >
      <LineChart
        data={sampleData}
        config={{
          height: 220,
          showGrid: true,
          showLabels: true,
          animated: true,
          duration: 1500,
          interactive: true,
        }}
      />
    </ChartContainer>
  );
}
```

#### Interactive Line Chart

**Example:** An interactive line chart with touch gestures

```tsx
// components/demo/charts/line-chart/line-chart-interactive.tsx
import { ChartContainer } from '@/components/charts/chart-container';
import { LineChart } from '@/components/charts/line-chart';
import React from 'react';

const sampleData = [
  { x: 'Q1', y: 45, label: 'Q1 2024' },
  { x: 'Q2', y: 67, label: 'Q2 2024' },
  { x: 'Q3', y: 52, label: 'Q3 2024' },
  { x: 'Q4', y: 89, label: 'Q4 2024' },
  { x: 'Q1', y: 95, label: 'Q1 2025' },
  { x: 'Q2', y: 110, label: 'Q2 2025' },
];

export function LineChartInteractive() {
  return (
    <ChartContainer
      title='Interactive Revenue Chart'
      description='Touch and drag to explore data points'
    >
      <LineChart
        data={sampleData}
        config={{
          height: 240,
          showGrid: true,
          showLabels: true,
          animated: true,
          duration: 1200,
          interactive: true,
          showYLabels: true,
          yLabelCount: 6,
        }}
      />
    </ChartContainer>
  );
}
```

#### Styled Line Chart

**Example:** A customized line chart with custom styling

```tsx
// components/demo/charts/line-chart/line-chart-styled.tsx
import { ChartContainer } from '@/components/charts/chart-container';
import { LineChart } from '@/components/charts/line-chart';
import { useColor } from '@/hooks/useColor';
import React from 'react';

const sampleData = [
  { x: 'Mon', y: 23, label: 'Monday' },
  { x: 'Tue', y: 45, label: 'Tuesday' },
  { x: 'Wed', y: 67, label: 'Wednesday' },
  { x: 'Thu', y: 34, label: 'Thursday' },
  { x: 'Fri', y: 89, label: 'Friday' },
  { x: 'Sat', y: 56, label: 'Saturday' },
  { x: 'Sun', y: 78, label: 'Sunday' },
];

export function LineChartStyled() {
  const borderColor = useColor('border');
  const backgroundColor = useColor('card');

  return (
    <ChartContainer
      title='Weekly Performance'
      description='Styled chart with custom appearance'
      style={{
        borderWidth: 1,
        borderColor: borderColor,
        backgroundColor: backgroundColor,
        borderRadius: 12,
        shadowColor: '#000',
        shadowOffset: { width: 0, height: 2 },
        shadowOpacity: 0.1,
        shadowRadius: 8,
        elevation: 4,
      }}
    >
      <LineChart
        data={sampleData}
        config={{
          height: 200,
          showGrid: true,
          showLabels: true,
          animated: true,
          duration: 2000,
          showYLabels: true,
          yLabelCount: 4,
          padding: 24,
        }}
      />
    </ChartContainer>
  );
}
```

#### Minimal Line Chart

**Example:** A minimal line chart

```tsx
// components/demo/charts/line-chart/line-chart-minimal.tsx
import { LineChart } from '@/components/charts/line-chart';
import React from 'react';

const sampleData = [
  { x: 1, y: 20 },
  { x: 2, y: 45 },
  { x: 3, y: 28 },
  { x: 4, y: 67 },
  { x: 5, y: 89 },
  { x: 6, y: 34 },
];

export function LineChartMinimal() {
  return (
    <LineChart
      data={sampleData}
      config={{
        height: 160,
        showGrid: false,
        showLabels: false,
        animated: true,
        duration: 800,
        showYLabels: false,
        padding: 16,
      }}
    />
  );
}
```

## API Reference

### LineChart

A customizable line chart component with smooth animations and interactive features.

| Prop     | Type               | Default | Description                                   |
| -------- | ------------------ | ------- | --------------------------------------------- |
| `data`   | `ChartDataPoint[]` | -       | Array of data points to display on the chart. |
| `config` | `ChartConfig`      | `{}`    | Configuration object for chart appearance.    |
| `style`  | `ViewStyle`        | -       | Additional styles to apply to the chart.      |

### ChartDataPoint

| Prop    | Type               | Description                          |
| ------- | ------------------ | ------------------------------------ |
| `x`     | `string \| number` | The x-axis value for the data point. |
| `y`     | `number`           | The y-axis value for the data point. |
| `label` | `string`           | Optional label for the data point.   |

### ChartConfig

| Prop          | Type      | Default | Description                                       |
| ------------- | --------- | ------- | ------------------------------------------------- |
| `width`       | `number`  | -       | Fixed width of the chart (auto-sizes if omitted). |
| `height`      | `number`  | `200`   | Height of the chart.                              |
| `padding`     | `number`  | `20`    | Padding around the chart.                         |
| `showGrid`    | `boolean` | `true`  | Whether to show grid lines.                       |
| `showLabels`  | `boolean` | `true`  | Whether to show x-axis labels.                    |
| `animated`    | `boolean` | `true`  | Whether to animate the chart on load.             |
| `duration`    | `number`  | `1000`  | Animation duration in milliseconds.               |
| `gradient`    | `boolean` | `false` | Whether to show gradient fill under the line.     |
| `interactive` | `boolean` | `false` | Whether to enable touch interactions.             |
| `showYLabels` | `boolean` | `true`  | Whether to show y-axis labels.                    |
| `yLabelCount` | `number`  | `5`     | Number of y-axis labels to display.               |
| `yAxisWidth`  | `number`  | `20`    | Width allocated for y-axis labels.                |

## Features

- **Smooth Animations**: Built-in animations using React Native Reanimated
- **Interactive Touch**: Optional touch gestures for data exploration
- **Responsive Design**: Automatically adapts to container width
- **Customizable Grid**: Optional grid lines for better readability
- **Gradient Fill**: Optional gradient fill under the line
- **Curved Lines**: Smooth bezier curves between data points
- **Smart Formatting**: Automatic number formatting (K, M suffixes)
- **Theme Integration**: Uses theme colors for consistent styling

## Accessibility

The LineChart component is built with accessibility in mind:

- Semantic SVG structure for screen readers
- Proper contrast ratios for visual elements
- Touch targets meet minimum size requirements
- Supports dynamic text sizing
- Keyboard navigation support (when interactive)

## Performance

The component is optimized for performance:

- Uses React Native Reanimated for smooth 60fps animations
- Efficient SVG rendering with minimal re-renders
- Gesture handling optimized for touch interactions
- Automatic cleanup of animation values
