---
title: "Weight Ruler"
description: "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."
canonical_url: "https://rn-carousel.dev/Examples/scenes/weight-ruler"
markdown_url: "https://rn-carousel.dev/Examples/scenes/weight-ruler.md"
version: "5"
---
# 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.

### 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

- [`itemSize`](https://rn-carousel.dev/props.md)
- [`itemAnimation`](https://rn-carousel.dev/custom-animations.md)
- [`renderWindowSize`](https://rn-carousel.dev/props.md)
- [`snapMode`](https://rn-carousel.dev/props.md)
- [`snapMomentum`](https://rn-carousel.dev/props.md)
- [`defaultIndex`](https://rn-carousel.dev/props.md)
- [`progress`](https://rn-carousel.dev/props.md)

## Full source

```tsx copy
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" },
});

```
