Week Capsules
A week of minutes: capsules fill in turn, glowing at the goal, and today's breathes inside a ring.
import { useEffect } from 'react';
import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
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 { easings, spring } from '../../lib/kinetik/motion/motion';
import { gradient } from '../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type WeekDay = {
/** One letter, e.g. "M". */
label: string;
minutes: number;
};
export type WeekCapsulesProps = {
/** The days, oldest first. The last one is today. */
days: WeekDay[];
/** Daily goal in minutes. */
goal: number;
/** Top of the scale in minutes. Defaults to twice the goal. */
max?: number;
/** Unit after the goal. Defaults to "MIN". */
unit?: string;
style?: StyleProp<ViewStyle>;
};
const CAPSULE_H = 66;
const CAPSULE_W = 14;
const MIN_FILL = CAPSULE_W;
/** The rounded average minutes across the days. */
export function averageMinutes(days: WeekDay[]): number {
if (days.length === 0) return 0;
return Math.round(days.reduce((sum, d) => sum + d.minutes, 0) / days.length);
}
/**
* A week of daily minutes: capsules fill from the bottom in turn, glowing once a
* day reaches the goal, and today's capsule breathes inside a bright ring.
*/
export function WeekCapsules({ days, goal, max, unit = 'MIN', style }: WeekCapsulesProps) {
const { colors, font } = useKinetikTheme();
const reduced = useReduceMotion();
const today = days[days.length - 1]?.minutes ?? 0;
const shown = Math.round(useCountUp(today, { instant: reduced }));
const average = averageMinutes(days);
const top = max ?? goal * 2;
return (
<View
accessible
accessibilityLabel={`Today ${today} of ${goal} ${unit}. Average ${average}.`}
style={[styles.card, style]}
>
<View style={styles.head}>
<Text
style={{
color: colors.highlight,
fontSize: font.size.lg,
fontWeight: font.weight.semibold,
fontVariant: ['tabular-nums'],
}}
>
{shown}
<Text style={{ color: MUTED, fontSize: font.size.xs }}>
/{goal} {unit}
</Text>
</Text>
<Text style={{ color: MUTED, fontSize: font.size.xs, fontWeight: font.weight.medium }}>
{average} AVG
</Text>
</View>
<View style={styles.body}>
<View style={styles.axis}>
<Text style={{ color: MUTED, fontSize: font.size.xs - 2 }}>{top}</Text>
<Text style={{ color: MUTED, fontSize: font.size.xs - 2 }}>0</Text>
</View>
{days.map((day, i) => (
<Capsule
key={`${day.label}-${i}`}
index={i}
label={day.label}
share={Math.min(1, day.minutes / top)}
met={day.minutes >= goal}
today={i === days.length - 1}
/>
))}
</View>
</View>
);
}
function Capsule({
index,
label,
share,
met,
today,
}: {
index: number;
label: string;
share: number;
met: boolean;
today: boolean;
}) {
const { colors, font } = useKinetikTheme();
const reduced = useReduceMotion();
const height = Math.max(MIN_FILL, share * CAPSULE_H);
const grown = useSharedValue(reduced ? height : 0);
const breath = useSharedValue(0);
useEffect(() => {
grown.set(reduced ? height : withDelay(index * 60, withSpring(height, spring('smooth'))));
}, [index, height, reduced, grown]);
useEffect(() => {
if (!today || reduced) {
breath.set(0);
return;
}
breath.set(withRepeat(withTiming(1, { duration: 1500, easing: easings.inOut }), -1, true));
}, [today, reduced, breath]);
const fill = useAnimatedStyle(() => ({ height: Math.max(0, grown.get()) }));
const ring = useAnimatedStyle(() => ({ opacity: 0.55 + breath.get() * 0.45 }));
return (
<View style={styles.column}>
<View style={styles.capsule}>
<Animated.View
style={[styles.fill, gradient(met ? MET : UNDER), met ? styles.glow : null, fill]}
/>
{today ? (
<Animated.View
pointerEvents="none"
style={[styles.ring, { borderColor: colors.highlight }, ring]}
/>
) : null}
</View>
<Text
style={{
color: today ? colors.highlight : MUTED,
fontSize: font.size.xs - 2,
fontWeight: today ? font.weight.bold : font.weight.regular,
}}
>
{label}
</Text>
</View>
);
}
const MUTED = 'hsl(180, 6%, 60%)';
const MET = 'linear-gradient(180deg, hsl(176, 92%, 72%) 0%, hsl(178, 76%, 44%) 100%)';
const UNDER = 'linear-gradient(180deg, hsl(182, 40%, 44%) 0%, hsl(184, 44%, 24%) 100%)';
const styles = StyleSheet.create({
card: {
width: '100%',
maxWidth: 320,
alignSelf: 'center',
borderRadius: 24,
borderWidth: 1,
borderColor: 'hsla(0, 0%, 100%, 0.08)',
backgroundColor: 'hsl(180, 8%, 5%)',
padding: 16,
gap: 10,
},
head: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between' },
body: { flexDirection: 'row', gap: 4 },
axis: { height: CAPSULE_H, justifyContent: 'space-between', paddingRight: 4 },
column: { flex: 1, alignItems: 'center', gap: 4 },
capsule: {
width: CAPSULE_W,
height: CAPSULE_H,
borderRadius: CAPSULE_W / 2,
justifyContent: 'flex-end',
backgroundColor: 'hsl(180, 6%, 16%)',
},
fill: { borderRadius: CAPSULE_W / 2 },
glow: { boxShadow: '0px 0px 10px hsla(176, 92%, 60%, 0.6)' },
ring: {
position: 'absolute',
top: -3,
left: -3,
right: -3,
bottom: -3,
borderWidth: 1.5,
borderRadius: CAPSULE_W / 2 + 3,
},
});Installation
npx kinetik-ui add week-capsulespnpm dlx kinetik-ui add week-capsulesbunx kinetik-ui add week-capsulesyarn dlx kinetik-ui add week-capsulesThe 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/week-capsules.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/week-capsules.tsx
Usage
import { WeekCapsules } from '@/components/kinetik/week-capsules';
<WeekCapsules
days={[
{ label: 'F', minutes: 22 },
{ label: 'S', minutes: 34 },
{ label: 'S', minutes: 18 },
{ label: 'M', minutes: 41 },
{ label: 'T', minutes: 38 },
{ label: 'W', minutes: 45 },
{ label: 'T', minutes: 19 },
]}
goal={30}
/>;When to use
- A daily habit with a goal: exercise, reading, meditation.
- A small widget beside other health or focus stats.
When not to use
- More than a week or so. Use Heat Grid.
- Values with no goal: nothing marks a good day.
Notes
- The last day is today.
- A capsule glows once its day reaches
goal.
Props
WeekCapsulesProps
| Prop | Type | Default | Description |
|---|---|---|---|
daysrequired | WeekDay[] | — | The days, oldest first. The last one is today. |
goalrequired | number | — | Daily goal in minutes. |
max | number | — | Top of the scale in minutes. Defaults to twice the goal. |
unit | string | 'MIN' | Unit after the goal. Defaults to "MIN". |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- Capsules use short UI-thread springs on arrival. Today's ring breathes as one view, so it animates at rest.
- Measured
Huawei P20 Lite (ANE-LX1), Android 9, release build · 18 ms median frame, 20 ms p90 · keeps drawing at rest · 125 MB app memory
Accessibility
- Reduced motion
- Capsules appear filled, the ring holds still and the number does not count.
- Screen readers
- One label ("Today 19 of 30 MIN. Average 31").
- Touch target
- Not interactive.
Source files
components/kinetik/week-capsules.tsx is in the Code tab of the preview above.