Kinetik UI

Credit Score

.md

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

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

Installation

Kinetik CLI
npx kinetik-ui add credit-score

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/credit-score.json

Install manually

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

Usage

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.

Props

CreditScoreProps

PropTypeDefaultDescription
scorerequirednumber—The score. 0 or below `min` means there is none yet.
minnumber300Bottom of the scale. Defaults to 300.
maxnumber850Top of the scale. Defaults to 850.
titlestring'Credit Score'
iconReactNode—Icon before the title.
action{ label: string; icon?: ReactNode; onPress: () => void }—A button in the header, e.g. Apply.
emptyHintstring'Feel free to build your credit history to see your score.'Shown under the score while there is none.
barsnumber32Number of bars in the meter. Defaults to 32.
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
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.

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.

Source files

components/kinetik/credit-score.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 },
});