# Credit Score

> A credit score card whose number counts up while a bar meter lights from red through amber to green.

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

## Install

```bash
npx kinetik-ui add credit-score
```

With the shadcn CLI:

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

## Usage

```tsx
import { CreditScore } from '@/components/kinetik/credit-score';

<CreditScore
  score={report?.score ?? 0}
  icon={<GaugeIcon />}
  action={{ label: 'Apply', icon: <PlusIcon />, onPress: startApplication }}
/>;
```

## When to use

- Showing one score on a known scale, with room to say what to do when there is none.

## When not to use

- Scores without bands or a scale: the colours imply good and bad.
- Several scores side by side: use a chart.

## Notes

- `min` and `max` default to 300 and 850. A score below `min` (such as 0) shows the empty state and `emptyHint`.
- Bands are Poor, Fair, Good, Very good and Excellent by share of the scale; `scoreBand` is exported if you need the same words elsewhere.

## CreditScoreProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `score` | `number` |  | The score. 0 or below `min` means there is none yet. |
| `min`? | `number` | `300` | Bottom of the scale. Defaults to 300. |
| `max`? | `number` | `850` | Top of the scale. Defaults to 850. |
| `title`? | `string` | `'Credit Score'` |  |
| `icon`? | `ReactNode` |  | Icon before the title. |
| `action`? | `{ label: string; icon?: ReactNode; onPress: () => void }` |  | A button in the header, e.g. Apply. |
| `emptyHint`? | `string` | `'Feel free to build your credit history to see your score.'` | Shown under the score while there is none. |
| `bars`? | `number` | `32` | Number of bars in the meter. Defaults to 32. |
| `style`? | `StyleProp<ViewStyle>` |  |  |

## Accessibility

- Reduced motion: The score shows at once and the bars light without the left-to-right sweep.
- Screen readers: The score reads as one line with its band and scale ("Your credit score is 712, Good, on a scale of 300 to 850").
- Touch target: The header button is at least 44 pt.

## Performance

The meter lights bar by bar with UI-thread timings; the number counts up for about a second (re-rendering on animation frames).

- 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 · 229 MB app memory. Janky frames come from the number counting up (a React re-render per frame for about a second).
- Real-device measurement pending.

## Source

`components/kinetik/credit-score.tsx`

```tsx
import { useEffect, type ReactNode } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
  interpolateColor,
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withTiming,
} from 'react-native-reanimated';

import { useCountUp } from '../../lib/kinetik/hooks/use-count-up';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { timing } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';

export type CreditScoreProps = {
  /** The score. 0 or below `min` means there is none yet. */
  score: number;
  /** Bottom of the scale. Defaults to 300. */
  min?: number;
  /** Top of the scale. Defaults to 850. */
  max?: number;
  title?: string;
  /** Icon before the title. */
  icon?: ReactNode;
  /** A button in the header, e.g. Apply. */
  action?: { label: string; icon?: ReactNode; onPress: () => void };
  /** Shown under the score while there is none. */
  emptyHint?: string;
  /** Number of bars in the meter. Defaults to 32. */
  bars?: number;
  style?: StyleProp<ViewStyle>;
};

/** Band names for a 300–850 style score, by share of the scale. */
export function scoreBand(share: number): string {
  if (share < 0.45) return 'Poor';
  if (share < 0.62) return 'Fair';
  if (share < 0.78) return 'Good';
  if (share < 0.9) return 'Very good';
  return 'Excellent';
}

/**
 * A credit score card: the number counts up in the sentence, and a meter of
 * thin bars lights from left to right in colours that warm from red through
 * amber to green, stopping at the score. With no score yet the bars rest
 * empty under a nudge to build history.
 */
export function CreditScore({
  score,
  min = 300,
  max = 850,
  title = 'Credit Score',
  icon,
  action,
  emptyHint = 'Feel free to build your credit history to see your score.',
  bars = 32,
  style,
}: CreditScoreProps) {
  const { colors, radius, space, font, shadow } = useKinetikTheme();
  const reduced = useReduceMotion();
  const has = score >= min;
  const share = has ? Math.min(1, (score - min) / (max - min)) : 0;
  const shown = useCountUp(has ? score : 0, { instant: reduced, duration: 1200 });
  const lit = Math.round(share * bars);

  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>
        {action ? (
          <Pressable
            accessibilityRole="button"
            onPress={action.onPress}
            style={({ pressed }) => [
              styles.button,
              {
                borderColor: colors.borderStrong,
                borderRadius: radius.md,
                gap: 6,
                backgroundColor: pressed ? colors.surfaceSunken : 'transparent',
              },
            ]}
          >
            {action.icon}
            <Text style={{ color: colors.text, fontSize: font.size.md }}>{action.label}</Text>
          </Pressable>
        ) : null}
      </View>
      <View style={[styles.rule, { backgroundColor: colors.border, marginHorizontal: space[5] }]} />
      <View
        accessible
        accessibilityLabel={
          has
            ? `Your credit score is ${score}, ${scoreBand(share)}, on a scale of ${min} to ${max}`
            : `No credit score yet. ${emptyHint}`
        }
        style={{ padding: space[5], paddingTop: space[4], gap: space[1] }}
      >
        <Text style={{ fontSize: font.size.xl + 2, color: colors.textSubtle }}>
          Your{' '}
          <Text style={{ color: colors.text, fontWeight: font.weight.medium }}>credit score</Text>{' '}
          is{' '}
          <Text
            style={{
              color: colors.text,
              fontWeight: font.weight.semibold,
              fontVariant: ['tabular-nums'],
            }}
          >
            {Math.round(shown)}
          </Text>
          {has ? (
            <Text style={{ color: colors.textMuted, fontSize: font.size.md }}>
              {'  '}
              {scoreBand(share)}
            </Text>
          ) : null}
        </Text>
        <Text style={{ color: colors.textSubtle, fontSize: font.size.md }}>
          {has ? `On a scale of ${min} to ${max}.` : emptyHint}
        </Text>
        <View style={[styles.meter, { marginTop: space[4] }]}>
          {Array.from({ length: bars }, (_, i) => (
            <MeterBar key={i} index={i} count={bars} on={i < lit} />
          ))}
        </View>
      </View>
    </View>
  );
}

/** One bar of the meter, lighting a beat after its left neighbour. */
function MeterBar({ index, count, on }: { index: number; count: number; on: boolean }) {
  const { colors } = useKinetikTheme();
  const reduced = useReduceMotion();
  const light = useSharedValue(on && reduced ? 1 : 0);

  useEffect(() => {
    if (reduced) {
      light.set(on ? 1 : 0);
      return;
    }
    light.set(
      withDelay(on ? index * 28 : (count - index) * 10, withTiming(on ? 1 : 0, timing('base'))),
    );
  }, [on, index, count, reduced, light]);

  // Red at the start of the scale, amber in the middle, green at the end.
  const t = count <= 1 ? 1 : index / (count - 1);
  const hue = t < 0.5 ? [colors.danger, colors.warning] : [colors.warning, colors.success];
  const local = t < 0.5 ? t * 2 : (t - 0.5) * 2;
  const empty = withAlpha(colors.text, 0.08);

  const style = useAnimatedStyle(() => {
    const tone = interpolateColor(local, [0, 1], hue as [string, string]);
    return {
      backgroundColor: interpolateColor(light.get(), [0, 1], [empty, tone]),
      transform: [{ scaleY: 0.82 + light.get() * 0.18 }],
    };
  });
  return <Animated.View style={[styles.bar, style]} />;
}

const styles = StyleSheet.create({
  card: { borderWidth: StyleSheet.hairlineWidth },
  head: { flexDirection: 'row', alignItems: 'center' },
  grow: { flex: 1 },
  button: {
    flexDirection: 'row',
    alignItems: 'center',
    minHeight: minTouchTarget,
    paddingHorizontal: 14,
    borderWidth: 1,
  },
  rule: { height: StyleSheet.hairlineWidth },
  meter: { flexDirection: 'row', justifyContent: 'space-between', height: 30 },
  bar: { width: 7, height: 30, borderRadius: 2 },
});
```
