# Month Bars

> Spending by month: bars rise in turn, the picked month lights teal and the figure counts to it.

- Category: Data
- Version: 1.0.0
- Platforms: iOS, Android, web
- Dependencies: `react-native-reanimated`, `react-native-svg`, `react-native-worklets`
- Requires: React Native New Architecture (Reanimated 4)

## Install

```bash
npx kinetik-ui add month-bars
```

With the shadcn CLI:

```bash
npx shadcn@latest add https://kinetik-ui.dev/r/month-bars.json
```

## Usage

```tsx
import { MonthBars } from '@/components/kinetik/month-bars';

<MonthBars
  months={[
    { label: 'MAY', value: 1840.5 },
    { label: 'JUN', value: 2410.2 },
    { label: 'JUL', value: 1975.75 },
    { label: 'AUG', value: 2120 },
    { label: 'SEP', value: 2187.19 },
    { label: 'OCT', value: 2316.3 },
  ]}
/>;
```

## When to use

- Monthly totals in a banking or budgeting app.
- Six or so periods where each one can be tapped.

## When not to use

- More than about eight months: targets get too narrow.
- Precise comparisons, as bars start from a raised floor. Use Bar Chart.

## Notes

- Bars are scaled from a raised floor, so small changes are easy to see.
- The last month starts selected.

## MonthBarsProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `months` | `MonthBar[]` |  | Months, oldest first. The last one starts selected. |
| `currency`? | `string` | `'$'` | Symbol before the figure. Defaults to "$". |
| `haptics`? | `boolean` | `true` | Vibrate on tap. Defaults to true. |
| `onSelect`? | `(index: number) => void` |  | Called with the index of a tapped month. |
| `style`? | `StyleProp<ViewStyle>` |  |  |

## Accessibility

- Reduced motion: Bars appear in place and the figure changes without counting.
- Screen readers: The figure reads as one line ("OCT: $2,316.30, +129.11") and each month as a button with its selected state.
- Touch target: Each month is the full column, at least 44 pt tall.

## Performance

Bars use short UI-thread springs on arrival; the figure re-renders only while it counts. Nothing animates at rest.

- Measured: Huawei P20 Lite (ANE-LX1), Android 9, release build · 11 ms median frame, 13 ms p90 · no frames drawn at rest · 117 MB app memory

## Source

`components/kinetik/month-bars.tsx`

```tsx
import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withSpring,
} from 'react-native-reanimated';
import Svg, { Path } from 'react-native-svg';

import { useCountUp } from '../../lib/kinetik/hooks/use-count-up';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { gradient } from '../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';

export type MonthBar = {
  /** Short label, e.g. "MAY". */
  label: string;
  value: number;
};

export type MonthBarsProps = {
  /** Months, oldest first. The last one starts selected. */
  months: MonthBar[];
  /** Symbol before the figure. Defaults to "$". */
  currency?: string;
  /** Vibrate on tap. Defaults to true. */
  haptics?: boolean;
  /** Called with the index of a tapped month. */
  onSelect?: (index: number) => void;
  style?: StyleProp<ViewStyle>;
};

const CHART = 62;
const FLOOR = 0.34;

function money(value: number) {
  return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}

/** The change from the month before, and "+" or "−" in front of it. */
export function monthDelta(months: MonthBar[], index: number): { sign: string; amount: string } {
  const change =
    (months[index]?.value ?? 0) - (months[index - 1]?.value ?? months[index]?.value ?? 0);
  return { sign: change < 0 ? '−' : '+', amount: money(Math.abs(change)) };
}

/**
 * Spending by month: bars rise from the floor in turn and the picked month lights
 * up in teal. Tap another month and the figure counts to it, cents dimmed.
 */
export function MonthBars({
  months,
  currency = '$',
  haptics = true,
  onSelect,
  style,
}: MonthBarsProps) {
  const { colors, font } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const [picked, setPicked] = useState(months.length - 1);
  const value = months[picked]?.value ?? 0;
  const delta = monthDelta(months, picked);
  const most = Math.max(...months.map((m) => m.value), 1);
  const least = Math.min(...months.map((m) => m.value));
  const range = Math.max(1, most - least);

  // Cents as a whole number, so the figure can never round to ".100".
  const cents = Math.round(useCountUp(value, { instant: reduced }) * 100);
  const whole = Math.floor(cents / 100).toLocaleString('en-US');
  const part = String(cents % 100).padStart(2, '0');

  return (
    <View style={[styles.card, style]}>
      <View
        accessible
        accessibilityLabel={`${months[picked]?.label}: ${currency}${money(value)}, ${delta.sign}${delta.amount}`}
        style={styles.head}
      >
        <Svg width={16} height={16} viewBox="0 0 24 24">
          <Path
            d="M3 6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2H3V6Zm0 5h18v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7Zm3 4v2h5v-2H6Z"
            fill={colors.highlight}
            fillRule="evenodd"
          />
        </Svg>
        <Text
          numberOfLines={1}
          style={{
            color: colors.highlight,
            fontSize: font.size.md,
            fontWeight: font.weight.semibold,
            fontVariant: ['tabular-nums'],
          }}
        >
          {currency} {whole}
          <Text style={{ color: MUTED }}>.{part}</Text>
        </Text>
        <Text
          numberOfLines={1}
          style={{ color: DELTA, fontSize: font.size.xs, fontWeight: font.weight.semibold }}
        >
          {delta.sign}
          {delta.amount}
        </Text>
      </View>

      <View style={styles.bars}>
        {months.map((month, i) => (
          <Column
            key={month.label}
            month={month}
            index={i}
            share={FLOOR + (1 - FLOOR) * ((month.value - least) / range)}
            picked={i === picked}
            onPress={() => {
              haptic('tick');
              setPicked(i);
              onSelect?.(i);
            }}
          />
        ))}
      </View>
    </View>
  );
}

function Column({
  month,
  index,
  share,
  picked,
  onPress,
}: {
  month: MonthBar;
  index: number;
  share: number;
  picked: boolean;
  onPress: () => void;
}) {
  const { colors, font } = useKinetikTheme();
  const reduced = useReduceMotion();
  const height = share * CHART;
  const grown = useSharedValue(reduced ? height : 0);
  useEffect(() => {
    grown.set(reduced ? height : withDelay(index * 55, withSpring(height, spring('smooth'))));
  }, [index, height, reduced, grown]);
  const rise = useAnimatedStyle(() => ({ height: Math.max(0, grown.get()) }));

  return (
    <Pressable
      accessibilityRole="button"
      accessibilityState={{ selected: picked }}
      accessibilityLabel={`${month.label}, ${month.value}`}
      onPress={onPress}
      style={styles.column}
    >
      <View style={styles.plot}>
        <Animated.View style={[styles.bar, gradient(picked ? PICKED : BAR), rise]} />
      </View>
      <Text
        style={{
          color: picked ? colors.highlight : MUTED,
          fontSize: font.size.xs - 2,
          fontWeight: picked ? font.weight.bold : font.weight.regular,
        }}
      >
        {month.label}
      </Text>
    </Pressable>
  );
}

const MUTED = 'hsl(0, 0%, 60%)';
const DELTA = 'hsl(354, 90%, 66%)';
const BAR = 'linear-gradient(180deg, hsl(0, 0%, 30%) 0%, hsl(0, 0%, 10%) 100%)';
const PICKED = 'linear-gradient(180deg, hsl(172, 72%, 56%) 0%, hsl(176, 70%, 18%) 100%)';

const styles = StyleSheet.create({
  card: {
    width: '100%',
    maxWidth: 320,
    alignSelf: 'center',
    borderRadius: 24,
    borderWidth: 1,
    borderColor: 'hsla(0, 0%, 100%, 0.08)',
    backgroundColor: 'hsl(0, 0%, 5%)',
    padding: 16,
    gap: 10,
  },
  head: { flexDirection: 'row', alignItems: 'center', gap: 6 },
  bars: { flexDirection: 'row', gap: 3 },
  column: { flex: 1, alignItems: 'center', gap: 4, minHeight: 44 },
  plot: { height: CHART, alignSelf: 'stretch', justifyContent: 'flex-end' },
  bar: {
    borderTopLeftRadius: 3,
    borderTopRightRadius: 3,
    borderTopWidth: 1.5,
    borderTopColor: 'hsla(0, 0%, 100%, 0.36)',
  },
});
```
