Gauge Meter
A dial that swings to its value on a spring while the figure counts up beneath it.
import { Canvas, Circle, Group, Path, Skia, SweepGradient, vec } from '@shopify/react-native-skia';
import { useEffect, useMemo } from 'react';
import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import { useDerivedValue, useSharedValue, withSpring } 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 { withAlpha } from '../../lib/kinetik/tokens/tokens';
export type GaugeMeterProps = {
value: number;
/** Defaults to 0. */
min?: number;
/** Defaults to 100. */
max?: number;
/** Under the figure, e.g. "Download speed". */
label?: string;
/** Formats the figure as it counts, and the two ends. Defaults to whole numbers. */
formatValue?: (value: number) => string;
/** Unit after the figure, e.g. "Mbps". */
unit?: string;
/** Width of the gauge. Defaults to 240. */
size?: number;
/** Width of the arc. Defaults to 14. */
thickness?: number;
/** Degrees of the arc, 180–300. Defaults to 240. */
sweep?: number;
/** Ticks around the outside. Defaults to true. */
ticks?: boolean;
/** Arc colours from the start to the tip. Defaults to sky into azure. */
colors?: readonly [string, string];
/** What the gauge measures, read before the value, e.g. "Download speed". */
accessibilityLabel: string;
style?: StyleProp<ViewStyle>;
};
/** Tick marks around the arc: every 5%, longer every 25%. */
const TICKS = 21;
/** Width of the labels under the arc's ends. */
const END_W = 64;
/**
* A dial that swings to its value on a spring while the figure counts up
* beneath it. The arc brightens from one end to the other, and a knob rides
* its tip.
*/
export function GaugeMeter({
value,
min = 0,
max = 100,
label,
formatValue = (v) => String(Math.round(v)),
unit,
size = 240,
thickness = 14,
sweep = 240,
ticks = true,
colors: ramp,
accessibilityLabel,
style,
}: GaugeMeterProps) {
const { colors, font } = useKinetikTheme();
const reduced = useReduceMotion();
const arc = Math.max(180, Math.min(300, sweep));
const share = max > min ? Math.max(0, Math.min(1, (value - min) / (max - min))) : 0;
const [from, to] = ramp ?? [colors.sky, colors.azure];
const c = size / 2;
const r = c - thickness / 2 - (ticks ? 14 : 2);
// The arc is open at the bottom. Its ends sit `lift` degrees below the horizontal.
const start = 90 + (360 - arc) / 2;
const lift = ((arc - 180) / 2) * (Math.PI / 180);
const endX = r * Math.cos(lift);
const endY = c + r * Math.sin(lift);
const labelTop = endY + thickness / 2 + 6;
const height = Math.ceil(labelTop + 18);
const { track, tickPath, majorPath } = useMemo(() => {
const oval = { x: c - r, y: c - r, width: r * 2, height: r * 2 };
const t = Skia.PathBuilder.Make().addArc(oval, 0, arc).detach();
const minor = Skia.PathBuilder.Make();
const major = Skia.PathBuilder.Make();
for (let i = 0; i < TICKS; i++) {
const a = ((arc * i) / (TICKS - 1)) * (Math.PI / 180);
const big = i % 5 === 0;
const inner = r + thickness / 2 + 5;
const outer = inner + (big ? 8 : 4);
const b = big ? major : minor;
b.moveTo(c + Math.cos(a) * inner, c + Math.sin(a) * inner);
b.lineTo(c + Math.cos(a) * outer, c + Math.sin(a) * outer);
}
return { track: t, tickPath: minor.detach(), majorPath: major.detach() };
}, [c, r, arc, thickness]);
const progress = useSharedValue(reduced ? share : 0);
useEffect(() => {
progress.set(withSpring(share, spring('smooth', reduced)));
}, [share, reduced, progress]);
const end = useDerivedValue(() => Math.max(0.0001, Math.min(1, progress.get())));
const knob = useDerivedValue(() => {
const a = arc * Math.max(0, Math.min(1, progress.get())) * (Math.PI / 180);
return vec(c + Math.cos(a) * r, c + Math.sin(a) * r);
});
const shown = useCountUp(value, { instant: reduced, duration: 1100, from: min });
const text = formatValue(shown);
return (
<View
accessible
accessibilityRole="progressbar"
accessibilityLabel={accessibilityLabel}
accessibilityValue={{
min,
max,
now: value,
text: `${formatValue(value)}${unit ? ` ${unit}` : ''}`,
}}
style={[{ width: size, height }, style]}
>
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
<Canvas style={StyleSheet.absoluteFill}>
{/* Drawn from 0°, then turned so the arc opens at the bottom. */}
<Group origin={vec(c, c)} transform={[{ rotate: (start * Math.PI) / 180 }]}>
{ticks ? (
<>
<Path
path={tickPath}
style="stroke"
strokeWidth={1.25}
strokeCap="round"
color={colors.borderStrong}
/>
<Path
path={majorPath}
style="stroke"
strokeWidth={1.75}
strokeCap="round"
color={colors.textSubtle}
/>
</>
) : null}
<Path
path={track}
style="stroke"
strokeWidth={thickness}
strokeCap="round"
color={colors.track}
/>
<Path path={track} style="stroke" strokeWidth={thickness} strokeCap="round" end={end}>
{/* The start colour also fills the open gap, so the round cap at 0° is not tinted by the tip. */}
<SweepGradient
c={vec(c, c)}
colors={[from, to, to, from]}
positions={[0, arc / 360, (arc + (360 - arc) / 2) / 360, 1]}
/>
</Path>
<Circle c={knob} r={thickness * 0.9} color={withAlpha(to, 0.25)} />
<Circle c={knob} r={thickness * 0.62} color={colors.surfaceRaised} />
<Circle c={knob} r={thickness * 0.3} color={to} />
</Group>
</Canvas>
</View>
<View pointerEvents="none" style={[styles.readout, { top: c - 34 }]}>
<View style={styles.figure}>
<Text
style={{
color: colors.text,
fontSize: Math.round(size * 0.18),
fontWeight: font.weight.bold,
letterSpacing: -1.2,
fontVariant: ['tabular-nums'],
}}
>
{text}
</Text>
{unit ? (
<Text
style={{
color: colors.textMuted,
fontSize: font.size.sm,
fontWeight: font.weight.semibold,
marginLeft: 4,
}}
>
{unit}
</Text>
) : null}
</View>
{label ? (
<Text style={{ color: colors.textMuted, fontSize: font.size.sm, marginTop: 2 }}>
{label}
</Text>
) : null}
</View>
{[min, max].map((v, i) => (
<Text
key={i}
style={[
styles.end,
{
top: labelTop,
left: c + (i === 0 ? -endX : endX) - END_W / 2,
color: colors.textSubtle,
fontSize: font.size.xs,
},
]}
>
{formatValue(v)}
</Text>
))}
</View>
);
}
const styles = StyleSheet.create({
readout: { position: 'absolute', left: 0, right: 0, alignItems: 'center' },
figure: { flexDirection: 'row', alignItems: 'baseline' },
end: { position: 'absolute', width: END_W, textAlign: 'center', fontVariant: ['tabular-nums'] },
});Installation
npx kinetik-ui add gauge-meterpnpm dlx kinetik-ui add gauge-meterbunx kinetik-ui add gauge-meteryarn dlx kinetik-ui add gauge-meterThe 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/gauge-meter.json
Install manually
- Install dependencies
npx expo install @shopify/react-native-skia react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/gauge-meter.tsx
Usage
import { GaugeMeter } from '@/components/kinetik/gauge-meter';
<GaugeMeter
accessibilityLabel="Download speed"
value={speed}
max={1000}
unit="Mbps"
label="Download"
/>;Change value and the dial swings from where it is, while the figure counts on. colors takes the arc's start and tip colours; by default it runs from sky into azure.
When to use
- One figure within a known range, read at a glance: a speed, a score, a battery level, a budget used.
- Live readings that change now and then, where the swing shows the direction of change.
When not to use
- Comparing several values. Use a bar chart.
- Values without a meaningful maximum. A dial suggests a limit.
Notes
sweepsets how much of the circle the arc covers, from 180° to 300°.- The figure counts with
formatValue, so pass one that rounds the way you want while it is counting. - With reduced motion the dial and the figure show the value at once.
Props
GaugeMeterProps
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | number | — | |
accessibilityLabelrequired | string | — | What the gauge measures, read before the value, e.g. "Download speed". |
min | number | 0 | Defaults to 0. |
max | number | 100 | Defaults to 100. |
label | string | — | Under the figure, e.g. "Download speed". |
formatValue | (value: number) => string | (v) => String(Math.round(v)) | Formats the figure as it counts, and the two ends. Defaults to whole numbers. |
unit | string | — | Unit after the figure, e.g. "Mbps". |
size | number | 240 | Width of the gauge. Defaults to 240. |
thickness | number | 14 | Width of the arc. Defaults to 14. |
sweep | number | 240 | Degrees of the arc, 180–300. Defaults to 240. |
ticks | boolean | true | Ticks around the outside. Defaults to true. |
colors | readonly [string, string] | — | Arc colours from the start to the tip. Defaults to sky into azure. |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- Arc and ticks are built once per size; the swing trims the arc and moves the knob from one shared value. The figure re-renders only while it counts.
- Measured
Real-device measurement pending.
Accessibility
- Reduced motion
- The arc and the figure show the value at once.
- Screen readers
- A progress bar with its minimum, maximum and formatted value.
- Touch target
- Not interactive.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/gauge-meter.tsx
import { Canvas, Circle, Group, Path, Skia, SweepGradient, vec } from '@shopify/react-native-skia';
import { useEffect, useMemo } from 'react';
import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import { useDerivedValue, useSharedValue, withSpring } 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 { withAlpha } from '../../lib/kinetik/tokens/tokens';
export type GaugeMeterProps = {
value: number;
/** Defaults to 0. */
min?: number;
/** Defaults to 100. */
max?: number;
/** Under the figure, e.g. "Download speed". */
label?: string;
/** Formats the figure as it counts, and the two ends. Defaults to whole numbers. */
formatValue?: (value: number) => string;
/** Unit after the figure, e.g. "Mbps". */
unit?: string;
/** Width of the gauge. Defaults to 240. */
size?: number;
/** Width of the arc. Defaults to 14. */
thickness?: number;
/** Degrees of the arc, 180–300. Defaults to 240. */
sweep?: number;
/** Ticks around the outside. Defaults to true. */
ticks?: boolean;
/** Arc colours from the start to the tip. Defaults to sky into azure. */
colors?: readonly [string, string];
/** What the gauge measures, read before the value, e.g. "Download speed". */
accessibilityLabel: string;
style?: StyleProp<ViewStyle>;
};
/** Tick marks around the arc: every 5%, longer every 25%. */
const TICKS = 21;
/** Width of the labels under the arc's ends. */
const END_W = 64;
/**
* A dial that swings to its value on a spring while the figure counts up
* beneath it. The arc brightens from one end to the other, and a knob rides
* its tip.
*/
export function GaugeMeter({
value,
min = 0,
max = 100,
label,
formatValue = (v) => String(Math.round(v)),
unit,
size = 240,
thickness = 14,
sweep = 240,
ticks = true,
colors: ramp,
accessibilityLabel,
style,
}: GaugeMeterProps) {
const { colors, font } = useKinetikTheme();
const reduced = useReduceMotion();
const arc = Math.max(180, Math.min(300, sweep));
const share = max > min ? Math.max(0, Math.min(1, (value - min) / (max - min))) : 0;
const [from, to] = ramp ?? [colors.sky, colors.azure];
const c = size / 2;
const r = c - thickness / 2 - (ticks ? 14 : 2);
// The arc is open at the bottom. Its ends sit `lift` degrees below the horizontal.
const start = 90 + (360 - arc) / 2;
const lift = ((arc - 180) / 2) * (Math.PI / 180);
const endX = r * Math.cos(lift);
const endY = c + r * Math.sin(lift);
const labelTop = endY + thickness / 2 + 6;
const height = Math.ceil(labelTop + 18);
const { track, tickPath, majorPath } = useMemo(() => {
const oval = { x: c - r, y: c - r, width: r * 2, height: r * 2 };
const t = Skia.PathBuilder.Make().addArc(oval, 0, arc).detach();
const minor = Skia.PathBuilder.Make();
const major = Skia.PathBuilder.Make();
for (let i = 0; i < TICKS; i++) {
const a = ((arc * i) / (TICKS - 1)) * (Math.PI / 180);
const big = i % 5 === 0;
const inner = r + thickness / 2 + 5;
const outer = inner + (big ? 8 : 4);
const b = big ? major : minor;
b.moveTo(c + Math.cos(a) * inner, c + Math.sin(a) * inner);
b.lineTo(c + Math.cos(a) * outer, c + Math.sin(a) * outer);
}
return { track: t, tickPath: minor.detach(), majorPath: major.detach() };
}, [c, r, arc, thickness]);
const progress = useSharedValue(reduced ? share : 0);
useEffect(() => {
progress.set(withSpring(share, spring('smooth', reduced)));
}, [share, reduced, progress]);
const end = useDerivedValue(() => Math.max(0.0001, Math.min(1, progress.get())));
const knob = useDerivedValue(() => {
const a = arc * Math.max(0, Math.min(1, progress.get())) * (Math.PI / 180);
return vec(c + Math.cos(a) * r, c + Math.sin(a) * r);
});
const shown = useCountUp(value, { instant: reduced, duration: 1100, from: min });
const text = formatValue(shown);
return (
<View
accessible
accessibilityRole="progressbar"
accessibilityLabel={accessibilityLabel}
accessibilityValue={{
min,
max,
now: value,
text: `${formatValue(value)}${unit ? ` ${unit}` : ''}`,
}}
style={[{ width: size, height }, style]}
>
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
<Canvas style={StyleSheet.absoluteFill}>
{/* Drawn from 0°, then turned so the arc opens at the bottom. */}
<Group origin={vec(c, c)} transform={[{ rotate: (start * Math.PI) / 180 }]}>
{ticks ? (
<>
<Path
path={tickPath}
style="stroke"
strokeWidth={1.25}
strokeCap="round"
color={colors.borderStrong}
/>
<Path
path={majorPath}
style="stroke"
strokeWidth={1.75}
strokeCap="round"
color={colors.textSubtle}
/>
</>
) : null}
<Path
path={track}
style="stroke"
strokeWidth={thickness}
strokeCap="round"
color={colors.track}
/>
<Path path={track} style="stroke" strokeWidth={thickness} strokeCap="round" end={end}>
{/* The start colour also fills the open gap, so the round cap at 0° is not tinted by the tip. */}
<SweepGradient
c={vec(c, c)}
colors={[from, to, to, from]}
positions={[0, arc / 360, (arc + (360 - arc) / 2) / 360, 1]}
/>
</Path>
<Circle c={knob} r={thickness * 0.9} color={withAlpha(to, 0.25)} />
<Circle c={knob} r={thickness * 0.62} color={colors.surfaceRaised} />
<Circle c={knob} r={thickness * 0.3} color={to} />
</Group>
</Canvas>
</View>
<View pointerEvents="none" style={[styles.readout, { top: c - 34 }]}>
<View style={styles.figure}>
<Text
style={{
color: colors.text,
fontSize: Math.round(size * 0.18),
fontWeight: font.weight.bold,
letterSpacing: -1.2,
fontVariant: ['tabular-nums'],
}}
>
{text}
</Text>
{unit ? (
<Text
style={{
color: colors.textMuted,
fontSize: font.size.sm,
fontWeight: font.weight.semibold,
marginLeft: 4,
}}
>
{unit}
</Text>
) : null}
</View>
{label ? (
<Text style={{ color: colors.textMuted, fontSize: font.size.sm, marginTop: 2 }}>
{label}
</Text>
) : null}
</View>
{[min, max].map((v, i) => (
<Text
key={i}
style={[
styles.end,
{
top: labelTop,
left: c + (i === 0 ? -endX : endX) - END_W / 2,
color: colors.textSubtle,
fontSize: font.size.xs,
},
]}
>
{formatValue(v)}
</Text>
))}
</View>
);
}
const styles = StyleSheet.create({
readout: { position: 'absolute', left: 0, right: 0, alignItems: 'center' },
figure: { flexDirection: 'row', alignItems: 'baseline' },
end: { position: 'absolute', width: END_W, textAlign: 'center', fontVariant: ['tabular-nums'] },
});