Kinetik UI

Expense Card

.md

A spending card with a period menu and horizontal bars on a dashed grid that grow in one by one.

v1.0.0Live on webreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add expense-card

The CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/expense-card.json

Install manually

  1. Install dependencies
    npx expo install react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/expense-card.tsx
    View source files

Usage

import { ExpenseCard } from '@/components/kinetik/expense-card';

<ExpenseCard
  icon={<ChartIcon />}
  rows={[
    { label: 'Housing', value: 6200 },
    { label: 'Utilities', value: 1400 },
    { label: 'Food', value: 2150 },
  ]}
  periods={['Weekly', 'Monthly', 'Yearly']}
  period={period}
  onPeriodChange={setPeriod}
/>;

When to use

  • A handful of categories compared on one scale, in a dashboard or finance overview.

When not to use

  • Trends over time: use a line or bar chart with a time axis.
  • More than six categories: the card gets long; show the top few and link to the rest.

Notes

  • The scale tops out at the largest value rounded up to a tidy number (niceMax), or at max.
  • Bars regrow from zero when the period changes. A category of 0 shows a small placeholder square.
  • Values format as 2K, 10K by default; pass formatValue for currencies.

Props

ExpenseCardProps

PropTypeDefaultDescription
rowsrequiredreadonly ExpenseRow[]—The rows for the current period. Values of 0 show an empty marker.
titlestring'Major Expenses'
iconReactNode—Icon before the title.
periodsreadonly P[]—Periods offered in the menu, e.g. ["Weekly", "Monthly", "Yearly"].
periodP—
onPeriodChange(period: P) => void—
maxnumber—Top of the scale. Defaults to the largest value rounded up to a tidy number.
formatValue(value: number) => stringcompactFormats axis labels and values. Defaults to 2K, 10K style.
colorstring—Bar colour. Defaults to the theme azure.
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
Each bar grows once per change with a UI-thread spring; the grid and labels are static views.
Measured

Pixel 9 emulator, API 35, release build · interaction median 1.00× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 154 MB app memory

Real-device measurement pending.

Accessibility

Reduced motion
Bars appear at their length and the menu opens without zooming.
Screen readers
The chart reads as one summary ("Housing 6.2K, Utilities 1.4K, Food 2.1K"); the period button reports whether its menu is open; options are menu items.
Touch target
The period button and menu options are at least 44 pt.

Source files

components/kinetik/expense-card.tsx
import { useEffect, useState, type ReactNode } from 'react';
import {
  Pressable,
  StyleSheet,
  Text,
  View,
  type LayoutChangeEvent,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import Animated, {
  FadeIn,
  FadeOut,
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withSpring,
  ZoomIn,
} from 'react-native-reanimated';

import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';

export type ExpenseRow = { label: string; value: number };

export type ExpenseCardProps<P extends string> = {
  title?: string;
  /** Icon before the title. */
  icon?: ReactNode;
  /** The rows for the current period. Values of 0 show an empty marker. */
  rows: readonly ExpenseRow[];
  /** Periods offered in the menu, e.g. ["Weekly", "Monthly", "Yearly"]. */
  periods?: readonly P[];
  period?: P;
  onPeriodChange?: (period: P) => void;
  /** Top of the scale. Defaults to the largest value rounded up to a tidy number. */
  max?: number;
  /** Formats axis labels and values. Defaults to 2K, 10K style. */
  formatValue?: (value: number) => string;
  /** Bar colour. Defaults to the theme azure. */
  color?: string;
  style?: StyleProp<ViewStyle>;
};

const LABEL = 76;
const BAR = 20;
const STEPS = 5;

const compact = (n: number) =>
  n >= 1000 ? `${Math.round((n / 1000) * 10) / 10}K`.replace('.0K', 'K') : String(Math.round(n));

/** The smallest of 1, 2, 2.5 or 5 times a power of ten that is at least `n`. */
export function niceMax(n: number): number {
  if (n <= 0) return 10;
  const p = 10 ** Math.floor(Math.log10(n));
  for (const m of [1, 2, 2.5, 5, 10]) if (m * p >= n) return m * p;
  return 10 * p;
}

/**
 * A spending card: a period menu in the header and horizontal bars on a
 * dashed grid. Bars grow in one after another, regrow when the period
 * changes, and an empty category shows a quiet placeholder square.
 */
export function ExpenseCard<P extends string>({
  title = 'Major Expenses',
  icon,
  rows,
  periods,
  period,
  onPeriodChange,
  max,
  formatValue = compact,
  color,
  style,
}: ExpenseCardProps<P>) {
  const { colors, radius, space, font, shadow } = useKinetikTheme();
  const reduced = useReduceMotion();
  const [plot, setPlot] = useState(0);
  const [open, setOpen] = useState(false);
  const top = max ?? niceMax(Math.max(0, ...rows.map((r) => r.value)));
  const fill = color ?? colors.azure;

  return (
    <View
      style={[
        styles.card,
        {
          backgroundColor: colors.surfaceRaised,
          borderColor: colors.border,
          borderRadius: radius.xl,
          boxShadow: shadow.md,
        },
        style,
      ]}
    >
      <View style={[styles.head, { padding: space[5], paddingBottom: space[4], gap: space[3] }]}>
        {icon}
        <Text
          accessibilityRole="header"
          style={[
            styles.grow,
            { color: colors.text, fontSize: font.size.lg, fontWeight: font.weight.medium },
          ]}
        >
          {title}
        </Text>
        {periods && period ? (
          <Pressable
            accessibilityRole="button"
            accessibilityLabel={`Period: ${period}`}
            accessibilityState={{ expanded: open }}
            onPress={() => setOpen((o) => !o)}
            style={[
              styles.select,
              { borderColor: colors.borderStrong, borderRadius: radius.md, gap: 6 },
            ]}
          >
            <Text style={{ color: colors.text, fontSize: font.size.md }}>{period}</Text>
            <View
              style={[
                styles.caret,
                { borderColor: colors.text, transform: [{ rotate: open ? '-135deg' : '45deg' }] },
              ]}
            />
          </Pressable>
        ) : null}
      </View>
      <View style={[styles.rule, { backgroundColor: colors.border, marginHorizontal: space[5] }]} />

      <View
        accessible
        accessibilityRole="summary"
        accessibilityLabel={rows.map((r) => `${r.label} ${formatValue(r.value)}`).join(', ')}
        style={{ padding: space[5], paddingTop: space[4] }}
      >
        <View style={styles.chart}>
          <View style={{ width: LABEL, gap: 12 }}>
            {rows.map((r) => (
              <Text
                key={r.label}
                numberOfLines={1}
                style={[styles.label, { color: colors.textSubtle, fontSize: font.size.md }]}
              >
                {r.label}
              </Text>
            ))}
          </View>
          <View
            style={styles.grow}
            onLayout={(e: LayoutChangeEvent) => setPlot(e.nativeEvent.layout.width)}
          >
            {/* Dashed grid lines at each step of the scale. */}
            <View pointerEvents="none" style={[StyleSheet.absoluteFill, styles.grid]}>
              {Array.from({ length: STEPS + 1 }, (_, i) => (
                <Dashes key={i} color={colors.borderStrong} />
              ))}
            </View>
            <View style={{ gap: 12 }}>
              {rows.map((r, i) => (
                <Bar
                  key={`${r.label}-${period ?? ''}`}
                  index={i}
                  value={r.value}
                  max={top}
                  width={plot}
                  color={fill}
                  empty={withAlpha(colors.text, 0.08)}
                />
              ))}
            </View>
          </View>
        </View>
        <View style={[styles.axis, { marginLeft: LABEL }]}>
          {Array.from({ length: STEPS + 1 }, (_, i) => (
            <Text
              key={i}
              style={{
                color: colors.textSubtle,
                fontSize: font.size.sm,
                fontVariant: ['tabular-nums'],
              }}
            >
              {formatValue((top / STEPS) * i)}
            </Text>
          ))}
        </View>
      </View>

      {open && periods && onPeriodChange ? (
        <Animated.View
          entering={reduced ? undefined : ZoomIn.duration(durations.base).easing(easings.standard)}
          exiting={reduced ? undefined : FadeOut.duration(durations.quick)}
          style={[
            styles.menu,
            {
              top: space[5] + 44,
              right: space[5],
              backgroundColor: colors.surfaceRaised,
              borderColor: colors.border,
              borderRadius: radius.md,
              boxShadow: shadow.lg,
            },
          ]}
        >
          {periods.map((p) => (
            <Pressable
              key={p}
              accessibilityRole="menuitem"
              accessibilityState={{ selected: p === period }}
              onPress={() => {
                setOpen(false);
                if (p !== period) onPeriodChange(p);
              }}
              style={({ pressed }) => [
                styles.option,
                {
                  backgroundColor: pressed || p === period ? colors.surfaceSunken : 'transparent',
                  borderRadius: radius.sm,
                },
              ]}
            >
              <Animated.Text
                entering={reduced ? undefined : FadeIn.duration(durations.base)}
                style={{
                  color: colors.text,
                  fontSize: font.size.md,
                  fontWeight: p === period ? font.weight.semibold : font.weight.regular,
                }}
              >
                {p}
              </Animated.Text>
            </Pressable>
          ))}
        </Animated.View>
      ) : null}
    </View>
  );
}

/** A vertical dashed line drawn as short segments, which renders the same everywhere. */
function Dashes({ color }: { color: string }) {
  return (
    <View style={styles.dashes}>
      {Array.from({ length: 14 }, (_, i) => (
        <View key={i} style={[styles.dash, { backgroundColor: color }]} />
      ))}
    </View>
  );
}

function Bar({
  index,
  value,
  max,
  width,
  color,
  empty,
}: {
  index: number;
  value: number;
  max: number;
  width: number;
  color: string;
  empty: string;
}) {
  const reduced = useReduceMotion();
  const grow = useSharedValue(reduced ? 1 : 0);
  const target = width * Math.min(1, Math.max(0, value / max));

  useEffect(() => {
    if (width <= 0) return;
    grow.set(reduced ? 1 : withDelay(index * 90, withSpring(1, spring('smooth'))));
  }, [width, index, reduced, grow]);

  const style = useAnimatedStyle(() => ({ width: Math.max(0, target * grow.get()) }));

  if (value <= 0) {
    return <View style={[styles.placeholder, { backgroundColor: empty }]} />;
  }
  return (
    <View style={styles.barSlot}>
      <Animated.View style={[styles.bar, { backgroundColor: color }, style]} />
    </View>
  );
}

const styles = StyleSheet.create({
  card: { borderWidth: StyleSheet.hairlineWidth },
  head: { flexDirection: 'row', alignItems: 'center' },
  grow: { flex: 1 },
  select: {
    flexDirection: 'row',
    alignItems: 'center',
    minHeight: minTouchTarget,
    paddingHorizontal: 14,
    borderWidth: 1,
  },
  caret: { width: 7, height: 7, borderRightWidth: 1.75, borderBottomWidth: 1.75, marginTop: -3 },
  rule: { height: StyleSheet.hairlineWidth },
  chart: { flexDirection: 'row' },
  label: { height: BAR, lineHeight: BAR },
  grid: { flexDirection: 'row', justifyContent: 'space-between', marginRight: -1 },
  dashes: { width: 1, justifyContent: 'space-between', paddingVertical: 2 },
  dash: { width: 1, height: 3 },
  barSlot: { height: BAR, justifyContent: 'center' },
  bar: { height: BAR, borderRadius: 5 },
  placeholder: { width: BAR, height: BAR, borderRadius: 5 },
  axis: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 10, marginRight: -6 },
  menu: {
    position: 'absolute',
    minWidth: 140,
    padding: 4,
    borderWidth: StyleSheet.hairlineWidth,
  },
  option: { minHeight: minTouchTarget, justifyContent: 'center', paddingHorizontal: 12 },
});