Weight Ruler
A weight log with an 801-tick ruler that only mounts the ticks near the needle, and a readout, BMI band and chart that follow it frame by frame.
Live demo. Drag it, tap it.
How it works
- Each tick is
itemSize12 and stands for 0.1 kg, from 40.0 to 120.0;defaultIndex284 opens on 68.4 kg. renderWindowSize={80}keeps only the ticks around the needle mounted, andsnapMode="nearest"withsnapMomentum0.3 lets a flick travel.progressfeeds derived values on the UI thread: the kg and BMI text, the WHO band marker and today's bar update every frame without a React render.
Uses
Full source
import { SceneScreen, useScene } from "@/features/scenes/kit";
import { LinearGradient } from "expo-linear-gradient";
import * as React from "react";
import {
Pressable,
type StyleProp,
StyleSheet,
Text,
TextInput,
type TextInputProps,
type TextStyle,
View,
} from "react-native";
import Animated, {
interpolate,
type SharedValue,
useAnimatedProps,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
} from "react-native-reanimated";
import {
Carousel,
type CarouselItemAnimation,
} from "react-native-reanimated-carousel";
import Svg, { Path } from "react-native-svg";
const MIN_KG = 40;
const TICKS = Array.from({ length: 801 }, (_, i) => i); // 40.0 to 120.0 kg in 0.1 kg steps
const START = 284; // 68.4 kg
const LAST_WEEK = 69.0;
const PAST_DAYS = [69.6, 69.3, 69.4, 69.0, 68.9, 68.7]; // the six days before today
const DAY_NAMES = Array.from({ length: 7 }, (_, k) =>
k === 6
? "Today"
: new Date(Date.now() - (6 - k) * 86400000).toLocaleDateString("en-US", {
weekday: "short",
}),
);
const HEIGHT_M = 1.75;
const TICK = 12; // px per 0.1 kg
const RULER_H = 100;
const INK = "#0F2E24";
const MUTED = "#5E7D72";
const GREEN = "#10B981";
const BG = "#F2FAF6";
// WHO adult BMI ranges, drawn on a 15 to 40 scale.
const BANDS = [
{
name: "Underweight",
from: 15,
to: 18.5,
color: "#7DB9F5",
tint: "#E0EFFE",
text: "#1E5A99",
},
{
name: "Normal",
from: 18.5,
to: 25,
color: "#34D399",
tint: "#D1FAE5",
text: "#065F46",
},
{
name: "Overweight",
from: 25,
to: 30,
color: "#FBBF24",
tint: "#FEF3C7",
text: "#92400E",
},
{
name: "Obese",
from: 30,
to: 40,
color: "#F87171",
tint: "#FEE2E2",
text: "#991B1B",
},
];
const bandOf = (bmi: number) => {
"worklet";
return bmi < 18.5 ? 0 : bmi < 25 ? 1 : bmi < 30 ? 2 : 3;
};
// Bricolage's tabular figures are 0.622 em wide and the decimal point 0.233 em.
const DIGIT_EM = 0.622;
const POINT_EM = 0.233;
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput);
const ACROSS = { start: { x: 0, y: 0 }, end: { x: 1, y: 0 } };
// Progress (a fractional tick index) to the kg shown above the needle.
const toKg = (p: number) => {
"worklet";
return MIN_KG + Math.round(Math.min(Math.max(p, 0), TICKS.length - 1)) / 10;
};
const format = (p: number) => {
"worklet";
return toKg(p).toFixed(1);
};
export default function Scene() {
return (
<SceneScreen
background={BG}
statusBar="dark"
fonts={[
"Bricolage-Bold",
"Instrument-Regular",
"Instrument-Medium",
"Instrument-SemiBold",
"JetBrainsMono-Medium",
]}
>
<WeightLog />
</SceneScreen>
);
}
function WeightLog() {
const { width, height, insets } = useScene();
const s = width / 402;
const roomy = height - insets.top - insets.bottom > 720; // room for the 7-day chart
const progress = useSharedValue(START);
// Everything below follows the ruler on the UI thread, frame by frame.
const kg = useDerivedValue(() => toKg(progress.value));
const bmi = useDerivedValue(() => kg.value / (HEIGHT_M * HEIGHT_M));
const kgText = useDerivedValue(() => format(progress.value));
const deltaText = useDerivedValue(() => {
const d = Math.round((kg.value - LAST_WEEK) * 10) / 10;
if (d === 0) return "Same as last week";
return `${d < 0 ? "−" : "+"}${Math.abs(d).toFixed(1)} kg vs last week`;
});
const itemAnimation = React.useCallback<CarouselItemAnimation>(
(value) => {
"worklet";
return {
transform: [{ translateX: value * TICK + (width - TICK) / 2 }],
opacity: interpolate(
Math.abs(value),
[0, 14, 34],
[1, 0.85, 0.1],
"clamp",
),
};
},
[width],
);
return (
<View
style={[
styles.screen,
{ paddingTop: insets.top, paddingBottom: insets.bottom + 12 },
]}
>
<View style={styles.topBar}>
<IconButton d="M15 5l-7 7 7 7" />
<Text style={styles.title}>Weight</Text>
<IconButton d="M3 12a9 9 0 1 0 3-6.7M3 4v5h5M12 7v5l3 2" />
</View>
<View style={styles.body}>
<View style={styles.header}>
<Text style={[styles.kicker, { fontSize: 13 * s }]}>
TODAY · 7:42 AM
</Text>
<BigReadout kg={kg} text={kgText} size={84 * s} />
<DeltaChip kg={kg} text={deltaText} />
</View>
<View style={{ height: RULER_H + 16 }}>
<Carousel
data={TICKS}
itemSize={TICK}
style={{ width, height: RULER_H, marginTop: 16 }}
defaultIndex={START}
renderWindowSize={80}
snapMode="nearest"
snapMomentum={0.3}
itemAnimation={itemAnimation}
progress={progress}
renderItem={({ index }) => <Tick index={index} />}
/>
<LinearGradient
{...ACROSS}
colors={[BG, `${BG}00`]}
style={[styles.fade, { left: 0 }]}
/>
<LinearGradient
{...ACROSS}
colors={[`${BG}00`, BG]}
style={[styles.fade, { right: 0 }]}
/>
<View style={[styles.needle, { left: width / 2 - 1.5 }]}>
<View style={styles.needleHead} />
</View>
</View>
<View style={styles.cards}>
{roomy ? <WeekCard kg={kg} /> : null}
<BmiCard bmi={bmi} barWidth={width - 64} s={s} />
</View>
</View>
<LogButton kg={kg} />
</View>
);
}
function IconButton({ d }: { d: string }) {
return (
<View style={styles.iconButton}>
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
<Path
d={d}
stroke={INK}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
</View>
);
}
const Tick = React.memo(function Tick({ index }: { index: number }) {
const major = index % 10 === 0;
const half = index % 5 === 0;
return (
<View style={styles.tickBox}>
<View
style={[styles.tick, major ? styles.major : half ? styles.half : null]}
/>
{major ? (
<Text style={styles.tickLabel}>{MIN_KG + index / 10}</Text>
) : null}
</View>
);
});
// A read-only TextInput whose text is set on the UI thread, so the numbers never re-render React.
type LiveTextProps = {
text: SharedValue<string>;
initial: string;
style: StyleProp<TextStyle>;
animatedStyle?: StyleProp<TextStyle>;
};
function LiveText({ text, initial, style, animatedStyle }: LiveTextProps) {
const animatedProps = useAnimatedProps(
() => ({ text: text.value }) as TextInputProps,
);
return (
<AnimatedTextInput
editable={false}
defaultValue={initial}
animatedProps={animatedProps}
underlineColorAndroid="transparent"
style={[styles.live, style, animatedStyle]}
/>
);
}
function BigReadout({
kg,
text,
size,
}: {
kg: SharedValue<number>;
text: SharedValue<string>;
size: number;
}) {
const spacing = -0.03 * size;
const digit = DIGIT_EM * size + spacing;
const boxW = Math.ceil(4 * digit + POINT_EM * size + spacing);
// The number is right-aligned in a box sized for "120.0"; slide the row so it stays centred.
const shift = useAnimatedStyle(() => ({
transform: [{ translateX: kg.value < 100 ? -digit / 2 : 0 }],
}));
return (
<Animated.View style={[styles.readout, shift]}>
<LiveText
text={text}
initial={format(START)}
style={[
styles.big,
{
width: boxW,
fontSize: size,
lineHeight: size * 1.1,
letterSpacing: spacing,
},
]}
/>
<Text
style={[
styles.unit,
{ fontSize: size * 0.27, marginBottom: size * 0.17 },
]}
>
kg
</Text>
</Animated.View>
);
}
function DeltaChip({
kg,
text,
}: {
kg: SharedValue<number>;
text: SharedValue<string>;
}) {
const color = useAnimatedStyle(() => ({
color: kg.value <= LAST_WEEK ? "#0E9F6E" : "#B45309",
}));
return (
<View style={styles.chip}>
<LiveText
text={text}
initial="−0.6 kg vs last week"
style={styles.chipText}
animatedStyle={color}
/>
</View>
);
}
function BmiCard({
bmi,
barWidth,
s,
}: {
bmi: SharedValue<number>;
barWidth: number;
s: number;
}) {
const value = useDerivedValue(() => bmi.value.toFixed(1));
const name = useDerivedValue(() => BANDS[bandOf(bmi.value)].name);
const badge = useAnimatedStyle(() => ({
backgroundColor: BANDS[bandOf(bmi.value)].tint,
}));
const badgeText = useAnimatedStyle(() => ({
color: BANDS[bandOf(bmi.value)].text,
}));
const x = (v: number) => {
"worklet";
return ((v - 15) / 25) * barWidth;
};
const marker = useAnimatedStyle(() => ({
transform: [{ translateX: x(Math.min(Math.max(bmi.value, 15), 40)) - 9 }],
}));
return (
<View style={styles.card}>
<Text style={styles.cardLabel}>Body mass index · 175 cm</Text>
<View style={styles.bmiRow}>
<LiveText
text={value}
initial="22.3"
style={[styles.bmi, { fontSize: 30 * s }]}
/>
<Animated.View style={[styles.badge, badge]}>
<LiveText
text={name}
initial="Normal"
style={styles.badgeText}
animatedStyle={badgeText}
/>
</Animated.View>
</View>
<View style={styles.bar}>
{BANDS.map((b) => (
<View
key={b.name}
style={[
styles.segment,
{ width: x(b.to) - x(b.from) - 3, backgroundColor: b.color },
]}
/>
))}
<Animated.View style={[styles.marker, marker]} />
</View>
<View style={styles.scale}>
{[18.5, 25, 30].map((v) => (
<Text key={v} style={[styles.scaleLabel, { left: x(v) - 20 }]}>
{v}
</Text>
))}
</View>
</View>
);
}
// Bars span 67 to 70 kg; today's bar follows the ruler.
const BAR_MAX = 68;
const barHeight = (kg: number) => {
"worklet";
return 8 + (Math.min(Math.max(kg, 67), 70) - 67) * 20;
};
function WeekCard({ kg }: { kg: SharedValue<number> }) {
// Slide a full-height pill inside a clipped one: a transform, so no layout pass per frame.
const today = useAnimatedStyle(() => ({
transform: [{ translateY: BAR_MAX - barHeight(kg.value) }],
}));
return (
<View style={styles.card}>
<View style={styles.cardTop}>
<Text style={styles.cardLabel}>Last 7 days</Text>
<Text style={styles.cardLabel}>Goal 66.0 kg</Text>
</View>
<View style={styles.week}>
{DAY_NAMES.map((day, k) => (
<View key={day} style={styles.day}>
{k < 6 ? (
<View
style={[styles.dayBar, { height: barHeight(PAST_DAYS[k]) }]}
/>
) : (
<View
style={[
styles.dayBar,
{ height: BAR_MAX, backgroundColor: "transparent" },
]}
>
<Animated.View style={[styles.todayBar, today]} />
</View>
)}
<Text style={[styles.dayName, k === 6 && styles.todayName]}>
{day}
</Text>
</View>
))}
</View>
</View>
);
}
function LogButton({ kg }: { kg: SharedValue<number> }) {
const [logged, setLogged] = React.useState<string | null>(null);
React.useEffect(() => {
if (!logged) return;
const t = setTimeout(() => setLogged(null), 1600);
return () => clearTimeout(t);
}, [logged]);
return (
<Pressable
onPress={() => setLogged(kg.value.toFixed(1))}
style={({ pressed }) => [styles.cta, pressed && { opacity: 0.85 }]}
>
<Text style={styles.ctaText}>
{logged ? `Logged ${logged} kg` : "Log weight"}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
screen: { flex: 1 },
body: { flex: 1, justifyContent: "space-evenly" },
topBar: {
height: 56,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
},
title: { fontFamily: "Instrument-SemiBold", fontSize: 17, color: INK },
iconButton: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#fff",
},
header: { alignItems: "center" },
kicker: { fontFamily: "Instrument-Medium", color: MUTED, letterSpacing: 0.6 },
readout: {
flexDirection: "row",
alignItems: "flex-end",
gap: 6,
marginTop: 8,
},
live: {
padding: 0,
margin: 0,
borderWidth: 0,
includeFontPadding: false,
pointerEvents: "none",
},
big: {
fontFamily: "Bricolage-Bold",
color: INK,
textAlign: "right",
fontVariant: ["tabular-nums"],
},
unit: { fontFamily: "Instrument-SemiBold", color: MUTED },
chip: {
marginTop: 12,
width: 220,
height: 32,
borderRadius: 16,
backgroundColor: "#fff",
justifyContent: "center",
},
chipText: {
fontFamily: "Instrument-Medium",
fontSize: 14,
textAlign: "center",
height: 20,
},
fade: {
position: "absolute",
top: 0,
bottom: 0,
width: 72,
pointerEvents: "none",
},
needle: {
position: "absolute",
top: 8,
width: 3,
height: 76,
borderRadius: 2,
backgroundColor: GREEN,
boxShadow: "0px 0px 0px 3px rgba(16,185,129,0.18)",
pointerEvents: "none",
},
needleHead: {
position: "absolute",
top: -6,
left: -6,
borderLeftWidth: 7.5,
borderRightWidth: 7.5,
borderTopWidth: 9,
borderLeftColor: "transparent",
borderRightColor: "transparent",
borderTopColor: GREEN,
},
tickBox: { width: TICK, height: RULER_H },
tick: {
position: "absolute",
left: 5,
top: 10,
width: 2,
height: 26,
borderRadius: 1,
backgroundColor: "#9DB8AE",
},
half: { height: 38, backgroundColor: "#6F9486" },
major: { height: 52, backgroundColor: INK },
tickLabel: {
position: "absolute",
top: 72,
left: -14,
width: 40,
textAlign: "center",
fontFamily: "JetBrainsMono-Medium",
fontSize: 13,
color: INK,
},
cards: { gap: 12 },
week: {
flexDirection: "row",
alignItems: "flex-end",
height: 92,
marginTop: 4,
},
day: { flex: 1, alignItems: "center", gap: 8 },
dayBar: {
width: 22,
borderRadius: 11,
overflow: "hidden",
backgroundColor: "#D5EAE1",
},
todayBar: { height: BAR_MAX, borderRadius: 11, backgroundColor: GREEN },
dayName: { fontFamily: "Instrument-Medium", fontSize: 12, color: MUTED },
todayName: { color: INK, fontFamily: "Instrument-SemiBold" },
card: {
marginHorizontal: 16,
padding: 16,
borderRadius: 20,
backgroundColor: "#fff",
boxShadow: "0px 6px 24px -12px rgba(15,46,36,0.25)",
},
cardTop: {
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
},
cardLabel: { fontFamily: "Instrument-Medium", fontSize: 13, color: MUTED },
bmiRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
bmi: { fontFamily: "Bricolage-Bold", color: INK, width: 90, height: 40 },
badge: { width: 112, height: 30, borderRadius: 15, justifyContent: "center" },
badgeText: {
fontFamily: "Instrument-SemiBold",
fontSize: 14,
textAlign: "center",
height: 20,
},
bar: {
flexDirection: "row",
gap: 3,
marginTop: 12,
height: 18,
alignItems: "center",
},
segment: { height: 8, borderRadius: 4 },
marker: {
position: "absolute",
left: 0,
width: 18,
height: 18,
borderRadius: 9,
borderWidth: 4,
borderColor: INK,
backgroundColor: "#fff",
},
scale: { height: 18, marginTop: 4 },
scaleLabel: {
position: "absolute",
width: 40,
textAlign: "center",
fontFamily: "JetBrainsMono-Medium",
fontSize: 11,
color: MUTED,
},
cta: {
marginHorizontal: 16,
height: 52,
borderRadius: 16,
backgroundColor: INK,
alignItems: "center",
justifyContent: "center",
},
ctaText: { fontFamily: "Instrument-SemiBold", fontSize: 16, color: "#fff" },
});
