Examples
Weight Ruler

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

  1. Each tick is itemSize 12 and stands for 0.1 kg, from 40.0 to 120.0; defaultIndex 284 opens on 68.4 kg.
  2. renderWindowSize={80} keeps only the ticks around the needle mounted, and snapMode="nearest" with snapMomentum 0.3 lets a flick travel.
  3. progress feeds 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" },
});