Score Matrix
A dark score card: the figure counts up as a dot grid lights to its share, beside rows of counting metrics.
import { BlurMask, Canvas, Group, Path, Skia } from '@shopify/react-native-skia';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import {
Platform,
StyleSheet,
Text,
View,
type StyleProp,
type ViewStyle,
type TextProps,
} from 'react-native';
import Animated, {
Easing,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withDelay,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useCountUp } from '../../lib/kinetik/hooks/use-count-up';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { palettes, withAlpha } from '../../lib/kinetik/tokens/tokens';
export type MatrixMetric = {
label: string;
value: number;
/** Small text after the value, e.g. "lbs". */
unit?: string;
/** Drawn inside the coloured disc. */
icon?: ReactNode;
/** Colour of the disc behind the icon. */
tone: string;
};
export type ScoreMatrixProps = {
/** Small caps label above the score. */
label: string;
score: number;
/** Top of the scale. Defaults to 100. */
max?: number;
metrics: readonly MatrixMetric[];
/** Colour of the lit dots. Defaults to a signal yellow. */
accent?: string;
/** Dot grid size. Defaults to 12 × 8. */
columns?: number;
rows?: number;
style?: StyleProp<ViewStyle>;
};
/** Largest gap between dot centres; narrower cards shrink the grid to fit. */
const STEP = 13;
const DOT = 2.7;
/** Dot centres: the bottom row first, left to right, then upwards. */
function dotAt(i: number, columns: number, rows: number, step: number) {
const col = i % columns;
const row = rows - 1 - Math.floor(i / columns);
return { x: col * step + step / 2, y: row * step + step / 2 };
}
const DISPLAY = Platform.select({
ios: 'Avenir Next',
android: 'sans-serif-light',
default: 'system-ui, sans-serif',
});
/**
* A dark score card: the big figure counts up while a grid of dots lights
* from the bottom-left, one dot at a time, up to the score's share of the
* scale. Beside it, rows of metrics slide in with their values counting.
*/
export function ScoreMatrix({
label,
score,
max = 100,
metrics,
accent = 'hsl(62, 96%, 58%)',
columns = 12,
rows = 8,
style,
}: ScoreMatrixProps) {
const { radius, space, font, shadow } = useKinetikTheme();
// A dark slab on either theme, so the lit dots glow.
const colors = palettes.dark;
const reduced = useReduceMotion();
// The score column's width sets the dot grid's size.
const [side, setSide] = useState(0);
const step = side > 0 ? Math.min(STEP, side / columns) : 0;
const dot = DOT * (step / STEP);
const total = columns * rows;
const lit = Math.round(Math.max(0, Math.min(1, score / max)) * total);
const progress = useSharedValue(reduced ? 1 : 0);
useEffect(() => {
if (reduced) {
progress.set(1);
return;
}
progress.set(0);
progress.set(withTiming(1, { duration: 1100, easing: Easing.out(Easing.cubic) }));
}, [lit, reduced, progress]);
const dim = useMemo(() => {
const b = Skia.PathBuilder.Make();
for (let i = 0; i < columns * rows; i++) {
const p = dotAt(i, columns, rows, step);
b.addCircle(p.x, p.y, dot);
}
return b.detach();
}, [columns, rows, step, dot]);
const points = useMemo(
() => Array.from({ length: lit }, (_, i) => dotAt(i, columns, rows, step)),
[lit, columns, rows, step],
);
const bright = useDerivedValue(() => {
const b = Skia.PathBuilder.Make();
const n = Math.round(progress.get() * points.length);
for (let i = 0; i < n; i++) b.addCircle(points[i]!.x, points[i]!.y, dot + 0.3);
return b.detach();
});
// The glow is blurred from the finished dots and fades in as they light: blurring a
// path that changes every frame would cache a new blurred mask per frame.
const halo = useMemo(() => {
const b = Skia.PathBuilder.Make();
for (const p of points) b.addCircle(p.x, p.y, dot + 0.3);
return b.detach();
}, [points, dot]);
const haloOn = useDerivedValue(() => progress.get() * 0.85);
const grid = step > 0 && (
<Canvas
style={{ width: columns * step, height: rows * step, marginTop: space[4] }}
pointerEvents="none"
>
<Path path={dim} color={withAlpha(colors.text, 0.1)} />
<Group opacity={haloOn}>
<Path path={halo} color={accent}>
<BlurMask blur={4} style="normal" />
</Path>
</Group>
<Path path={bright} color={accent} />
</Canvas>
);
return (
<View
style={[
styles.card,
{
backgroundColor: colors.surface,
borderColor: colors.border,
borderRadius: radius.xl + 4,
padding: space[5],
gap: space[5],
boxShadow: shadow.lg,
},
style,
]}
>
<View
accessible
accessibilityLabel={`${label}: ${score} out of ${max}`}
onLayout={(e) => setSide(e.nativeEvent.layout.width)}
style={styles.side}
>
<Text
style={{
color: colors.textSubtle,
fontSize: font.size.xs + 1,
fontWeight: font.weight.semibold,
letterSpacing: 1.6,
}}
>
{label.toUpperCase()}
</Text>
<Counted
value={score}
duration={1100}
format={(n) => String(Math.round(n))}
style={{
color: colors.text,
fontFamily: DISPLAY,
fontSize: side > 0 ? Math.min(76, side * 0.55) : 76,
lineHeight: side > 0 ? Math.min(84, side * 0.62) : 84,
fontWeight: '300',
letterSpacing: -3,
fontVariant: ['tabular-nums'],
}}
/>
{grid}
</View>
<View style={styles.list}>
{metrics.map((m, i) => (
<Row key={m.label} metric={m} order={i} last={i === metrics.length - 1} />
))}
</View>
</View>
);
}
function Row({ metric, order, last }: { metric: MatrixMetric; order: number; last: boolean }) {
const { font } = useKinetikTheme();
const colors = palettes.dark;
const reduced = useReduceMotion();
const shown = useCountUp(metric.value, { instant: reduced, duration: 900 + order * 120 });
const enter = useSharedValue(reduced ? 1 : 0);
useEffect(() => {
if (!reduced) enter.set(withDelay(150 + order * 90, withSpring(1, spring('smooth'))));
}, [order, reduced, enter]);
const style = useAnimatedStyle(() => ({
opacity: enter.get(),
transform: [{ translateX: (1 - enter.get()) * 18 }],
}));
const whole = Number.isInteger(metric.value);
return (
<Animated.View
accessible
accessibilityLabel={`${metric.label}: ${metric.value}${metric.unit ? ` ${metric.unit}` : ''}`}
style={[
styles.row,
!last && { borderBottomWidth: StyleSheet.hairlineWidth, borderColor: colors.borderStrong },
style,
]}
>
<View style={[styles.disc, { backgroundColor: metric.tone }]}>{metric.icon}</View>
<Text
numberOfLines={1}
style={[
styles.grow,
{
color: colors.text,
fontSize: font.size.sm,
fontWeight: font.weight.semibold,
letterSpacing: 1.4,
},
]}
>
{metric.label.toUpperCase()}
</Text>
<Text
style={{
color: colors.text,
fontFamily: DISPLAY,
fontSize: font.size.xl + 2,
fontWeight: '300',
fontVariant: ['tabular-nums'],
}}
>
{whole ? Math.round(shown) : shown.toFixed(1)}
{metric.unit ? (
<Text style={{ fontSize: font.size.sm, color: colors.textMuted }}> {metric.unit}</Text>
) : null}
</Text>
</Animated.View>
);
}
/** A figure counting up, in its own component so each count re-renders only this text. */
function Counted({
value,
duration,
format,
...text
}: TextProps & { value: number; duration: number; format: (n: number) => string }) {
const reduced = useReduceMotion();
const shown = useCountUp(value, { instant: reduced, duration });
return <Text {...text}>{format(shown)}</Text>;
}
const styles = StyleSheet.create({
card: { flexDirection: 'row', borderWidth: StyleSheet.hairlineWidth },
side: { width: '38%' },
list: { flex: 1 },
row: { flexDirection: 'row', alignItems: 'center', gap: 14, minHeight: 60 },
disc: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
},
grow: { flex: 1 },
});Installation
npx kinetik-ui add score-matrixpnpm dlx kinetik-ui add score-matrixbunx kinetik-ui add score-matrixyarn dlx kinetik-ui add score-matrixThe 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/score-matrix.json
Install manually
- Install dependencies
npx expo install @shopify/react-native-skia react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/score-matrix.tsx
Usage
import { ScoreMatrix } from '@/components/kinetik/score-matrix';
<ScoreMatrix
label="Athletic score"
score={13}
metrics={[
{ label: 'Sleep', value: 88, tone: '#E2622B', icon: <MoonIcon /> },
{ label: 'Strength', value: 170, unit: 'lbs', tone: '#6E7A44', icon: <DumbbellIcon /> },
{ label: 'Steps', value: 1982, tone: '#9C82D6', icon: <FootprintsIcon /> },
]}
/>;When to use
- One headline score with the handful of figures that feed it, on a dashboard or a weekly recap.
When not to use
- Comparing scores over time: use a chart.
Notes
- The dot grid lights
score / maxof its dots, bottom row first.columnsandrowsset its size (12 × 8 by default). - The card is dark on both themes so the lit dots glow;
accentchanges their colour. - The score always sits beside the metrics; on narrow cards the score and its dot grid shrink to fit their column.
- Whole numbers count up as whole numbers; fractional values keep one decimal.
Props
ScoreMatrixProps
| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | — | Small caps label above the score. |
scorerequired | number | — | |
metricsrequired | readonly MatrixMetric[] | — | |
max | number | 100 | Top of the scale. Defaults to 100. |
accent | string | 'hsl(62, 96%, 58%)' | Colour of the lit dots. Defaults to a signal yellow. |
columns | number | 12 | Dot grid size. Defaults to 12 × 8. |
rows | number | 8 | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- The dot grid is two Skia paths, the lit one rebuilt on the UI thread for about a second; figures count up by re-rendering their text. Nothing animates at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.06× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 166 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- The score, dots and metric values appear at once.
- Screen readers
- The score reads as one line ("Athletic score: 13 out of 100") and each metric as one line ("Strength: 170 lbs").
- Touch target
- Nothing is interactive.
Source files
components/kinetik/score-matrix.tsx
import { BlurMask, Canvas, Group, Path, Skia } from '@shopify/react-native-skia';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import {
Platform,
StyleSheet,
Text,
View,
type StyleProp,
type ViewStyle,
type TextProps,
} from 'react-native';
import Animated, {
Easing,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withDelay,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useCountUp } from '../../lib/kinetik/hooks/use-count-up';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { palettes, withAlpha } from '../../lib/kinetik/tokens/tokens';
export type MatrixMetric = {
label: string;
value: number;
/** Small text after the value, e.g. "lbs". */
unit?: string;
/** Drawn inside the coloured disc. */
icon?: ReactNode;
/** Colour of the disc behind the icon. */
tone: string;
};
export type ScoreMatrixProps = {
/** Small caps label above the score. */
label: string;
score: number;
/** Top of the scale. Defaults to 100. */
max?: number;
metrics: readonly MatrixMetric[];
/** Colour of the lit dots. Defaults to a signal yellow. */
accent?: string;
/** Dot grid size. Defaults to 12 × 8. */
columns?: number;
rows?: number;
style?: StyleProp<ViewStyle>;
};
/** Largest gap between dot centres; narrower cards shrink the grid to fit. */
const STEP = 13;
const DOT = 2.7;
/** Dot centres: the bottom row first, left to right, then upwards. */
function dotAt(i: number, columns: number, rows: number, step: number) {
const col = i % columns;
const row = rows - 1 - Math.floor(i / columns);
return { x: col * step + step / 2, y: row * step + step / 2 };
}
const DISPLAY = Platform.select({
ios: 'Avenir Next',
android: 'sans-serif-light',
default: 'system-ui, sans-serif',
});
/**
* A dark score card: the big figure counts up while a grid of dots lights
* from the bottom-left, one dot at a time, up to the score's share of the
* scale. Beside it, rows of metrics slide in with their values counting.
*/
export function ScoreMatrix({
label,
score,
max = 100,
metrics,
accent = 'hsl(62, 96%, 58%)',
columns = 12,
rows = 8,
style,
}: ScoreMatrixProps) {
const { radius, space, font, shadow } = useKinetikTheme();
// A dark slab on either theme, so the lit dots glow.
const colors = palettes.dark;
const reduced = useReduceMotion();
// The score column's width sets the dot grid's size.
const [side, setSide] = useState(0);
const step = side > 0 ? Math.min(STEP, side / columns) : 0;
const dot = DOT * (step / STEP);
const total = columns * rows;
const lit = Math.round(Math.max(0, Math.min(1, score / max)) * total);
const progress = useSharedValue(reduced ? 1 : 0);
useEffect(() => {
if (reduced) {
progress.set(1);
return;
}
progress.set(0);
progress.set(withTiming(1, { duration: 1100, easing: Easing.out(Easing.cubic) }));
}, [lit, reduced, progress]);
const dim = useMemo(() => {
const b = Skia.PathBuilder.Make();
for (let i = 0; i < columns * rows; i++) {
const p = dotAt(i, columns, rows, step);
b.addCircle(p.x, p.y, dot);
}
return b.detach();
}, [columns, rows, step, dot]);
const points = useMemo(
() => Array.from({ length: lit }, (_, i) => dotAt(i, columns, rows, step)),
[lit, columns, rows, step],
);
const bright = useDerivedValue(() => {
const b = Skia.PathBuilder.Make();
const n = Math.round(progress.get() * points.length);
for (let i = 0; i < n; i++) b.addCircle(points[i]!.x, points[i]!.y, dot + 0.3);
return b.detach();
});
// The glow is blurred from the finished dots and fades in as they light: blurring a
// path that changes every frame would cache a new blurred mask per frame.
const halo = useMemo(() => {
const b = Skia.PathBuilder.Make();
for (const p of points) b.addCircle(p.x, p.y, dot + 0.3);
return b.detach();
}, [points, dot]);
const haloOn = useDerivedValue(() => progress.get() * 0.85);
const grid = step > 0 && (
<Canvas
style={{ width: columns * step, height: rows * step, marginTop: space[4] }}
pointerEvents="none"
>
<Path path={dim} color={withAlpha(colors.text, 0.1)} />
<Group opacity={haloOn}>
<Path path={halo} color={accent}>
<BlurMask blur={4} style="normal" />
</Path>
</Group>
<Path path={bright} color={accent} />
</Canvas>
);
return (
<View
style={[
styles.card,
{
backgroundColor: colors.surface,
borderColor: colors.border,
borderRadius: radius.xl + 4,
padding: space[5],
gap: space[5],
boxShadow: shadow.lg,
},
style,
]}
>
<View
accessible
accessibilityLabel={`${label}: ${score} out of ${max}`}
onLayout={(e) => setSide(e.nativeEvent.layout.width)}
style={styles.side}
>
<Text
style={{
color: colors.textSubtle,
fontSize: font.size.xs + 1,
fontWeight: font.weight.semibold,
letterSpacing: 1.6,
}}
>
{label.toUpperCase()}
</Text>
<Counted
value={score}
duration={1100}
format={(n) => String(Math.round(n))}
style={{
color: colors.text,
fontFamily: DISPLAY,
fontSize: side > 0 ? Math.min(76, side * 0.55) : 76,
lineHeight: side > 0 ? Math.min(84, side * 0.62) : 84,
fontWeight: '300',
letterSpacing: -3,
fontVariant: ['tabular-nums'],
}}
/>
{grid}
</View>
<View style={styles.list}>
{metrics.map((m, i) => (
<Row key={m.label} metric={m} order={i} last={i === metrics.length - 1} />
))}
</View>
</View>
);
}
function Row({ metric, order, last }: { metric: MatrixMetric; order: number; last: boolean }) {
const { font } = useKinetikTheme();
const colors = palettes.dark;
const reduced = useReduceMotion();
const shown = useCountUp(metric.value, { instant: reduced, duration: 900 + order * 120 });
const enter = useSharedValue(reduced ? 1 : 0);
useEffect(() => {
if (!reduced) enter.set(withDelay(150 + order * 90, withSpring(1, spring('smooth'))));
}, [order, reduced, enter]);
const style = useAnimatedStyle(() => ({
opacity: enter.get(),
transform: [{ translateX: (1 - enter.get()) * 18 }],
}));
const whole = Number.isInteger(metric.value);
return (
<Animated.View
accessible
accessibilityLabel={`${metric.label}: ${metric.value}${metric.unit ? ` ${metric.unit}` : ''}`}
style={[
styles.row,
!last && { borderBottomWidth: StyleSheet.hairlineWidth, borderColor: colors.borderStrong },
style,
]}
>
<View style={[styles.disc, { backgroundColor: metric.tone }]}>{metric.icon}</View>
<Text
numberOfLines={1}
style={[
styles.grow,
{
color: colors.text,
fontSize: font.size.sm,
fontWeight: font.weight.semibold,
letterSpacing: 1.4,
},
]}
>
{metric.label.toUpperCase()}
</Text>
<Text
style={{
color: colors.text,
fontFamily: DISPLAY,
fontSize: font.size.xl + 2,
fontWeight: '300',
fontVariant: ['tabular-nums'],
}}
>
{whole ? Math.round(shown) : shown.toFixed(1)}
{metric.unit ? (
<Text style={{ fontSize: font.size.sm, color: colors.textMuted }}> {metric.unit}</Text>
) : null}
</Text>
</Animated.View>
);
}
/** A figure counting up, in its own component so each count re-renders only this text. */
function Counted({
value,
duration,
format,
...text
}: TextProps & { value: number; duration: number; format: (n: number) => string }) {
const reduced = useReduceMotion();
const shown = useCountUp(value, { instant: reduced, duration });
return <Text {...text}>{format(shown)}</Text>;
}
const styles = StyleSheet.create({
card: { flexDirection: 'row', borderWidth: StyleSheet.hairlineWidth },
side: { width: '38%' },
list: { flex: 1 },
row: { flexDirection: 'row', alignItems: 'center', gap: 14, minHeight: 60 },
disc: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
},
grow: { flex: 1 },
});