Agenda Card
A day-at-a-glance widget: the date lit by a blue glow, events sliding in, and a bar that fills for the one under way.
import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated';
import Svg, { Path } from 'react-native-svg';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring, timing } from '../../lib/kinetik/motion/motion';
import { gradient } from '../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { glow, minTouchTarget, palettes, withAlpha } from '../../lib/kinetik/tokens/tokens';
export type AgendaEvent = {
id: string;
title: string;
/** Start and end times as shown, e.g. "10:00" and "10:30". */
start: string;
end: string;
/** Colour of the event's bar. Defaults to a blue, violet and teal rotation. */
color?: string;
/** For the event happening now: how far through it is, 0–1. Its bar fills to match. */
progress?: number;
};
export type AgendaCardProps = {
/** The big date, e.g. "Oct 30". */
date: string;
/** The day under it, e.g. "Thursday". */
weekday: string;
/** Events in order. The first `limit` are listed. */
events: readonly AgendaEvent[];
/** How many events the day has, if more than are listed. Defaults to `events.length`. */
total?: number;
/** Most events listed at once. Defaults to 3. */
limit?: number;
/** Heading over the list. Defaults to "Upcoming". */
heading?: string;
/** Shows the round add button. */
onAdd?: () => void;
onEventPress?: (event: AgendaEvent) => void;
/** A tap on add. Defaults to true. */
haptics?: boolean;
style?: StyleProp<ViewStyle>;
};
// Plus from Tabler Icons (MIT, https://tabler.io/icons), via iconstack.io.
const PLUS = 'M12 5l0 14 M5 12l14 0';
const TONES = ['hsl(224, 90%, 62%)', 'hsl(272, 72%, 62%)', 'hsl(188, 80%, 50%)'];
/**
* A day-at-a-glance widget: the date on a panel lit from below by a blue
* glow, and the next events sliding in one after another, each with a colour
* bar that fills for the event under way. The add button turns as it is
* pressed, and new events slide in at their place in the list.
*/
export function AgendaCard({
date,
weekday,
events,
total,
limit = 3,
heading = 'Upcoming',
onAdd,
onEventPress,
haptics = true,
style,
}: AgendaCardProps) {
const { radius, space, font } = useKinetikTheme();
// A night-blue slab on either theme, so the glow reads.
const colors = palettes.dark;
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const count = total ?? events.length;
const shown = events.slice(0, limit);
// Events there from the start stagger in; ones added later slide in at once.
const [first] = useState(() => new Set(events.map((e) => e.id)));
const light = useSharedValue(reduced ? 1 : 0);
useEffect(() => {
if (!reduced) light.set(withTiming(1, { duration: 900 }));
}, [reduced, light]);
const bump = useSharedValue(1);
useEffect(() => {
if (!reduced)
bump.set(withSequence(withTiming(0.7, { duration: 0 }), withSpring(1, spring('bouncy'))));
}, [count, reduced, bump]);
const turn = useSharedValue(0);
const glowStyle = useAnimatedStyle(() => ({
opacity: light.get(),
transform: [{ translateY: (1 - light.get()) * 30 }],
}));
const countStyle = useAnimatedStyle(() => ({ transform: [{ scale: bump.get() }] }));
const plus = useAnimatedStyle(() => ({ transform: [{ rotate: `${turn.get()}deg` }] }));
return (
<View
style={[
styles.card,
{
backgroundColor: 'hsl(224, 20%, 14%)',
borderColor: withAlpha(colors.highlight, 0.08),
borderRadius: radius.xl + 4,
padding: space[3],
gap: space[4],
},
style,
]}
>
<View
accessible
accessibilityLabel={`${weekday}, ${date}, ${count} ${count === 1 ? 'event' : 'events'}`}
style={[styles.panel, { borderRadius: radius.xl - 4, padding: space[4] }]}
>
<Animated.View
pointerEvents="none"
style={[
StyleSheet.absoluteFill,
gradient(
'linear-gradient(180deg, hsl(226, 32%, 6%) 0%, hsl(226, 34%, 8%) 42%, hsl(222, 78%, 42%) 82%, hsl(214, 95%, 72%) 100%)',
),
glowStyle,
]}
/>
<Text
numberOfLines={1}
adjustsFontSizeToFit
style={{
color: colors.highlight,
fontSize: font.size['3xl'],
fontWeight: font.weight.semibold,
letterSpacing: font.tracking.tight,
}}
>
{date}
</Text>
<View style={styles.push} />
<Text
style={{
color: colors.highlight,
fontSize: font.size.lg,
fontWeight: font.weight.semibold,
}}
>
{weekday}
</Text>
<Animated.View style={[styles.countRow, countStyle]}>
<Text
style={{
color: colors.highlight,
fontSize: font.size.lg,
fontWeight: font.weight.semibold,
}}
>
{count} {count === 1 ? 'event' : 'events'}
</Text>
</Animated.View>
</View>
<View style={[styles.list, { gap: space[2], paddingTop: space[2] }]}>
<Text
accessibilityRole="header"
style={{ color: colors.textMuted, fontSize: font.size.sm, letterSpacing: 0.6 }}
>
{heading.toUpperCase()}
</Text>
{shown.map((event, i) => (
<Row
key={event.id}
event={event}
tone={event.color ?? TONES[i % TONES.length]!}
delay={first.has(event.id) ? 160 + i * 110 : 0}
onPress={onEventPress ? () => onEventPress(event) : undefined}
/>
))}
</View>
{onAdd ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Add event"
onPress={() => {
haptic('tap');
if (!reduced)
turn.set(
withSequence(withSpring(90, spring('snappy')), withTiming(0, { duration: 0 })),
);
onAdd();
}}
style={({ pressed }) => [
styles.fab,
{
backgroundColor: colors.surface,
borderColor: withAlpha(colors.sky, 0.55),
boxShadow: glow(colors.sky),
transform: [{ scale: pressed ? 0.92 : 1 }],
},
]}
>
<Animated.View style={plus}>
<Svg width={22} height={22} viewBox="0 0 24 24" fill="none">
<Path d={PLUS} stroke={colors.highlight} strokeWidth={2.2} strokeLinecap="round" />
</Svg>
</Animated.View>
</Pressable>
) : null}
</View>
);
}
function Row({
event,
tone,
delay,
onPress,
}: {
event: AgendaEvent;
tone: string;
delay: number;
onPress?: () => void;
}) {
const { font } = useKinetikTheme();
const colors = palettes.dark;
const reduced = useReduceMotion();
const enter = useSharedValue(reduced ? 1 : 0);
const share = Math.max(0, Math.min(1, event.progress ?? 0));
const fillTo = useSharedValue(reduced ? share : 0);
// The delay only matters on mount.
const [wait] = useState(delay);
useEffect(() => {
if (reduced) return;
enter.set(withDelay(wait, withSpring(1, spring('smooth'))));
}, [wait, reduced, enter]);
useEffect(() => {
fillTo.set(reduced ? share : withDelay(400, withTiming(share, timing('slow'))));
}, [share, reduced, fillTo]);
const style = useAnimatedStyle(() => ({
opacity: enter.get(),
transform: [{ translateX: (1 - enter.get()) * 16 }],
}));
const fill = useAnimatedStyle(() => ({ height: `${fillTo.get() * 100}%` }));
const now = event.progress !== undefined;
const label = `${event.title}, ${event.start} to ${event.end}${now ? ', now' : ''}`;
const content = (
<>
<View
style={[styles.bar, { backgroundColor: now ? withAlpha(colors.highlight, 0.16) : tone }]}
>
{now ? <Animated.View style={[styles.barFill, { backgroundColor: tone }, fill]} /> : null}
</View>
<View style={styles.grow}>
<Text numberOfLines={1} style={{ color: colors.text, fontSize: font.size.md + 1 }}>
{event.title}
</Text>
<Text
style={{
color: colors.textSubtle,
fontSize: font.size.sm,
fontVariant: ['tabular-nums'],
}}
>
{event.start} - {event.end}
</Text>
</View>
</>
);
return (
<Animated.View style={style}>
{onPress ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={onPress}
style={styles.row}
>
{content}
</Pressable>
) : (
<View accessible accessibilityLabel={label} style={styles.row}>
{content}
</View>
)}
</Animated.View>
);
}
const styles = StyleSheet.create({
card: { flexDirection: 'row', borderWidth: 1 },
panel: {
width: '46%',
minHeight: 200,
overflow: 'hidden',
backgroundColor: 'hsl(226, 32%, 6%)',
},
push: { flex: 1 },
countRow: { alignSelf: 'flex-start', transformOrigin: 'left center' },
list: { flex: 1, minWidth: 0 },
row: { flexDirection: 'row', gap: 12, minHeight: minTouchTarget, alignItems: 'stretch' },
bar: { width: 3, borderRadius: 2, overflow: 'hidden' },
barFill: { width: 3, borderRadius: 2 },
grow: { flex: 1, minWidth: 0, justifyContent: 'center' },
fab: {
position: 'absolute',
right: 14,
bottom: 14,
width: 48,
height: 48,
borderRadius: 24,
borderWidth: 1.5,
alignItems: 'center',
justifyContent: 'center',
},
});Installation
npx kinetik-ui add agenda-cardpnpm dlx kinetik-ui add agenda-cardbunx kinetik-ui add agenda-cardyarn dlx kinetik-ui add agenda-cardThe 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/agenda-card.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-svg react-native-worklets - Copy the source files
components/kinetik/agenda-card.tsx
Usage
import { AgendaCard } from '@/components/kinetik/agenda-card';
<AgendaCard
date="Oct 30"
weekday="Thursday"
events={[
{ id: '1', title: 'Team standup', start: '09:30', end: '09:45' },
{ id: '2', title: 'Deep work', start: '10:00', end: '12:00', progress: 0.4 },
{ id: '3', title: 'Design review', start: '13:30', end: '14:30' },
]}
onAdd={openNewEvent}
onEventPress={(e) => router.push(`/events/${e.id}`)}
/>;When to use
- A home-screen style glance at today: the date, a count and the next few events.
When not to use
- Full calendars or long agendas: use Month Calendar or Timeline.
Notes
- The first
limitevents are listed (3 by default).totalsets the count on the date panel when the day has more. - Give the event under way a
progress(0–1): its bar dims and fills to match, and screen readers hear "now". - Events added later slide in at once; the ones there from the start stagger in.
- The card is night-blue on both themes so the glow reads. The add button only shows with
onAdd.
Props
AgendaCardProps
| Prop | Type | Default | Description |
|---|---|---|---|
daterequired | string | — | The big date, e.g. "Oct 30". |
weekdayrequired | string | — | The day under it, e.g. "Thursday". |
eventsrequired | readonly AgendaEvent[] | — | Events in order. The first `limit` are listed. |
total | number | — | How many events the day has, if more than are listed. Defaults to `events.length`. |
limit | number | 3 | Most events listed at once. Defaults to 3. |
heading | string | 'Upcoming' | Heading over the list. Defaults to "Upcoming". |
onAdd | () => void | — | Shows the round add button. |
onEventPress | (event: AgendaEvent) => void | — | |
haptics | boolean | true | A tap on add. Defaults to true. |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- The glow, rows and fill run short UI-thread springs and timings on arrival and on change. Nothing animates at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.00× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 150 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- The glow, rows and fills appear in place; the add button does not turn.
- Screen readers
- The date reads as one line ("Thursday, Oct 30, 6 events"), each event as one line ("Focus Time, 11:00 to 13:00, now"), and the add button as "Add event".
- Touch target
- Event rows are at least 44 pt tall; the add button is 48 pt.
Source files
components/kinetik/agenda-card.tsx
import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated';
import Svg, { Path } from 'react-native-svg';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring, timing } from '../../lib/kinetik/motion/motion';
import { gradient } from '../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { glow, minTouchTarget, palettes, withAlpha } from '../../lib/kinetik/tokens/tokens';
export type AgendaEvent = {
id: string;
title: string;
/** Start and end times as shown, e.g. "10:00" and "10:30". */
start: string;
end: string;
/** Colour of the event's bar. Defaults to a blue, violet and teal rotation. */
color?: string;
/** For the event happening now: how far through it is, 0–1. Its bar fills to match. */
progress?: number;
};
export type AgendaCardProps = {
/** The big date, e.g. "Oct 30". */
date: string;
/** The day under it, e.g. "Thursday". */
weekday: string;
/** Events in order. The first `limit` are listed. */
events: readonly AgendaEvent[];
/** How many events the day has, if more than are listed. Defaults to `events.length`. */
total?: number;
/** Most events listed at once. Defaults to 3. */
limit?: number;
/** Heading over the list. Defaults to "Upcoming". */
heading?: string;
/** Shows the round add button. */
onAdd?: () => void;
onEventPress?: (event: AgendaEvent) => void;
/** A tap on add. Defaults to true. */
haptics?: boolean;
style?: StyleProp<ViewStyle>;
};
// Plus from Tabler Icons (MIT, https://tabler.io/icons), via iconstack.io.
const PLUS = 'M12 5l0 14 M5 12l14 0';
const TONES = ['hsl(224, 90%, 62%)', 'hsl(272, 72%, 62%)', 'hsl(188, 80%, 50%)'];
/**
* A day-at-a-glance widget: the date on a panel lit from below by a blue
* glow, and the next events sliding in one after another, each with a colour
* bar that fills for the event under way. The add button turns as it is
* pressed, and new events slide in at their place in the list.
*/
export function AgendaCard({
date,
weekday,
events,
total,
limit = 3,
heading = 'Upcoming',
onAdd,
onEventPress,
haptics = true,
style,
}: AgendaCardProps) {
const { radius, space, font } = useKinetikTheme();
// A night-blue slab on either theme, so the glow reads.
const colors = palettes.dark;
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const count = total ?? events.length;
const shown = events.slice(0, limit);
// Events there from the start stagger in; ones added later slide in at once.
const [first] = useState(() => new Set(events.map((e) => e.id)));
const light = useSharedValue(reduced ? 1 : 0);
useEffect(() => {
if (!reduced) light.set(withTiming(1, { duration: 900 }));
}, [reduced, light]);
const bump = useSharedValue(1);
useEffect(() => {
if (!reduced)
bump.set(withSequence(withTiming(0.7, { duration: 0 }), withSpring(1, spring('bouncy'))));
}, [count, reduced, bump]);
const turn = useSharedValue(0);
const glowStyle = useAnimatedStyle(() => ({
opacity: light.get(),
transform: [{ translateY: (1 - light.get()) * 30 }],
}));
const countStyle = useAnimatedStyle(() => ({ transform: [{ scale: bump.get() }] }));
const plus = useAnimatedStyle(() => ({ transform: [{ rotate: `${turn.get()}deg` }] }));
return (
<View
style={[
styles.card,
{
backgroundColor: 'hsl(224, 20%, 14%)',
borderColor: withAlpha(colors.highlight, 0.08),
borderRadius: radius.xl + 4,
padding: space[3],
gap: space[4],
},
style,
]}
>
<View
accessible
accessibilityLabel={`${weekday}, ${date}, ${count} ${count === 1 ? 'event' : 'events'}`}
style={[styles.panel, { borderRadius: radius.xl - 4, padding: space[4] }]}
>
<Animated.View
pointerEvents="none"
style={[
StyleSheet.absoluteFill,
gradient(
'linear-gradient(180deg, hsl(226, 32%, 6%) 0%, hsl(226, 34%, 8%) 42%, hsl(222, 78%, 42%) 82%, hsl(214, 95%, 72%) 100%)',
),
glowStyle,
]}
/>
<Text
numberOfLines={1}
adjustsFontSizeToFit
style={{
color: colors.highlight,
fontSize: font.size['3xl'],
fontWeight: font.weight.semibold,
letterSpacing: font.tracking.tight,
}}
>
{date}
</Text>
<View style={styles.push} />
<Text
style={{
color: colors.highlight,
fontSize: font.size.lg,
fontWeight: font.weight.semibold,
}}
>
{weekday}
</Text>
<Animated.View style={[styles.countRow, countStyle]}>
<Text
style={{
color: colors.highlight,
fontSize: font.size.lg,
fontWeight: font.weight.semibold,
}}
>
{count} {count === 1 ? 'event' : 'events'}
</Text>
</Animated.View>
</View>
<View style={[styles.list, { gap: space[2], paddingTop: space[2] }]}>
<Text
accessibilityRole="header"
style={{ color: colors.textMuted, fontSize: font.size.sm, letterSpacing: 0.6 }}
>
{heading.toUpperCase()}
</Text>
{shown.map((event, i) => (
<Row
key={event.id}
event={event}
tone={event.color ?? TONES[i % TONES.length]!}
delay={first.has(event.id) ? 160 + i * 110 : 0}
onPress={onEventPress ? () => onEventPress(event) : undefined}
/>
))}
</View>
{onAdd ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Add event"
onPress={() => {
haptic('tap');
if (!reduced)
turn.set(
withSequence(withSpring(90, spring('snappy')), withTiming(0, { duration: 0 })),
);
onAdd();
}}
style={({ pressed }) => [
styles.fab,
{
backgroundColor: colors.surface,
borderColor: withAlpha(colors.sky, 0.55),
boxShadow: glow(colors.sky),
transform: [{ scale: pressed ? 0.92 : 1 }],
},
]}
>
<Animated.View style={plus}>
<Svg width={22} height={22} viewBox="0 0 24 24" fill="none">
<Path d={PLUS} stroke={colors.highlight} strokeWidth={2.2} strokeLinecap="round" />
</Svg>
</Animated.View>
</Pressable>
) : null}
</View>
);
}
function Row({
event,
tone,
delay,
onPress,
}: {
event: AgendaEvent;
tone: string;
delay: number;
onPress?: () => void;
}) {
const { font } = useKinetikTheme();
const colors = palettes.dark;
const reduced = useReduceMotion();
const enter = useSharedValue(reduced ? 1 : 0);
const share = Math.max(0, Math.min(1, event.progress ?? 0));
const fillTo = useSharedValue(reduced ? share : 0);
// The delay only matters on mount.
const [wait] = useState(delay);
useEffect(() => {
if (reduced) return;
enter.set(withDelay(wait, withSpring(1, spring('smooth'))));
}, [wait, reduced, enter]);
useEffect(() => {
fillTo.set(reduced ? share : withDelay(400, withTiming(share, timing('slow'))));
}, [share, reduced, fillTo]);
const style = useAnimatedStyle(() => ({
opacity: enter.get(),
transform: [{ translateX: (1 - enter.get()) * 16 }],
}));
const fill = useAnimatedStyle(() => ({ height: `${fillTo.get() * 100}%` }));
const now = event.progress !== undefined;
const label = `${event.title}, ${event.start} to ${event.end}${now ? ', now' : ''}`;
const content = (
<>
<View
style={[styles.bar, { backgroundColor: now ? withAlpha(colors.highlight, 0.16) : tone }]}
>
{now ? <Animated.View style={[styles.barFill, { backgroundColor: tone }, fill]} /> : null}
</View>
<View style={styles.grow}>
<Text numberOfLines={1} style={{ color: colors.text, fontSize: font.size.md + 1 }}>
{event.title}
</Text>
<Text
style={{
color: colors.textSubtle,
fontSize: font.size.sm,
fontVariant: ['tabular-nums'],
}}
>
{event.start} - {event.end}
</Text>
</View>
</>
);
return (
<Animated.View style={style}>
{onPress ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={onPress}
style={styles.row}
>
{content}
</Pressable>
) : (
<View accessible accessibilityLabel={label} style={styles.row}>
{content}
</View>
)}
</Animated.View>
);
}
const styles = StyleSheet.create({
card: { flexDirection: 'row', borderWidth: 1 },
panel: {
width: '46%',
minHeight: 200,
overflow: 'hidden',
backgroundColor: 'hsl(226, 32%, 6%)',
},
push: { flex: 1 },
countRow: { alignSelf: 'flex-start', transformOrigin: 'left center' },
list: { flex: 1, minWidth: 0 },
row: { flexDirection: 'row', gap: 12, minHeight: minTouchTarget, alignItems: 'stretch' },
bar: { width: 3, borderRadius: 2, overflow: 'hidden' },
barFill: { width: 3, borderRadius: 2 },
grow: { flex: 1, minWidth: 0, justifyContent: 'center' },
fab: {
position: 'absolute',
right: 14,
bottom: 14,
width: 48,
height: 48,
borderRadius: 24,
borderWidth: 1.5,
alignItems: 'center',
justifyContent: 'center',
},
});