Topic Picker
Topic chips drifting in rows that run opposite ways; picking one pops it in a soft colour.
import { useEffect, useState } from 'react';
import {
Pressable,
ScrollView,
StyleSheet,
Text,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated';
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, withAlpha } from '../../lib/kinetik/tokens/tokens';
export type Topic = {
id: string;
label: string;
/** An emoji or short glyph before the label. */
emoji?: string;
};
export type TopicPickerProps = {
topics: readonly Topic[];
/** Ids of the picked topics. */
selected: readonly string[];
onChange: (selected: string[]) => void;
title?: string;
subtitle?: string;
/** Rows of drifting chips. Topics are dealt across them in turn. Defaults to 3. */
rows?: number;
/** Drift speed in pt per second. Defaults to 22. */
speed?: number;
/** The button under the rows. Disabled until `minimum` topics are picked. */
submit?: { label: string; onPress: (selected: string[]) => void };
/** Defaults to 1. */
minimum?: number;
/** A tick on each pick. Defaults to true. */
haptics?: boolean;
style?: StyleProp<ViewStyle>;
};
/**
* Topic chips drifting past in rows that run in opposite directions, fading
* out at the edges. Picking one pops it and washes it in a soft colour; the
* button underneath wakes up once enough are picked.
*/
export function TopicPicker({
topics,
selected,
onChange,
title = 'Select your topics',
subtitle = 'Choose topics that you are interested in',
rows = 3,
speed = 22,
submit,
minimum = 1,
haptics = true,
style,
}: TopicPickerProps) {
const { colors, radius, space, font, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const lines = Array.from({ length: rows }, (_, r) => topics.filter((_, i) => i % rows === r));
const tints = [colors.azure, colors.warning, colors.danger, colors.success, colors.sky];
const ready = selected.length >= minimum;
const toggle = (id: string) => {
haptic('tick');
onChange(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]);
};
const fade = withAlpha(colors.surfaceRaised, 0);
return (
<View
style={[
styles.panel,
{
backgroundColor: colors.surfaceRaised,
borderRadius: radius.xl + 8,
paddingVertical: space[6],
gap: space[6],
boxShadow: shadow.lg,
},
style,
]}
>
<View style={{ alignItems: 'center', gap: space[1], paddingHorizontal: space[5] }}>
<Text
accessibilityRole="header"
style={{
color: colors.text,
fontSize: font.size['2xl'],
fontWeight: font.weight.bold,
textAlign: 'center',
}}
>
{title}
</Text>
<Text style={{ color: colors.textMuted, fontSize: font.size.md, textAlign: 'center' }}>
{subtitle}
</Text>
</View>
<View style={{ gap: space[3] }}>
{lines.map((line, r) => (
<Row
key={r}
topics={line}
direction={r % 2 === 0 ? 'left' : 'right'}
speed={speed * (1 + r * 0.18)}
still={reduced}
selected={selected}
tintFor={(id) => tints[topics.findIndex((t) => t.id === id) % tints.length]!}
onToggle={toggle}
/>
))}
<View
pointerEvents="none"
style={[
styles.edge,
styles.edgeLeft,
gradient(`linear-gradient(90deg, ${colors.surfaceRaised} 0%, ${fade} 100%)`),
]}
/>
<View
pointerEvents="none"
style={[
styles.edge,
styles.edgeRight,
gradient(`linear-gradient(270deg, ${colors.surfaceRaised} 0%, ${fade} 100%)`),
]}
/>
</View>
{submit ? (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: !ready }}
accessibilityHint={`${selected.length} selected`}
disabled={!ready}
onPress={() => submit.onPress([...selected])}
style={({ pressed }) => [
styles.submit,
{
marginHorizontal: space[5],
borderRadius: radius.pill,
backgroundColor: ready ? colors.azureSoft : colors.surfaceSunken,
boxShadow: ready ? glow(colors.azure) : undefined,
transform: [{ scale: pressed ? 0.98 : 1 }],
},
]}
>
<Text
style={{
color: ready ? colors.text : colors.textSubtle,
fontSize: font.size.md,
fontWeight: font.weight.bold,
letterSpacing: font.tracking.wide,
}}
>
{submit.label.toUpperCase()}
</Text>
</Pressable>
) : null}
</View>
);
}
function Row({
topics,
direction,
speed,
still,
selected,
tintFor,
onToggle,
}: {
topics: readonly Topic[];
direction: 'left' | 'right';
speed: number;
still: boolean;
selected: readonly string[];
tintFor: (id: string) => string;
onToggle: (id: string) => void;
}) {
const [span, setSpan] = useState(0);
const x = useSharedValue(0);
useEffect(() => {
if (still || span <= 0) return;
const from = direction === 'left' ? 0 : -span;
const to = direction === 'left' ? -span : 0;
x.set(from);
x.set(
withRepeat(withTiming(to, { duration: (span / speed) * 1000, easing: Easing.linear }), -1),
);
}, [span, speed, direction, still, x]);
const drift = useAnimatedStyle(() => ({ transform: [{ translateX: x.get() }] }));
const chips = (copy: number) => (
<View
style={styles.copy}
onLayout={copy === 0 ? (e) => setSpan(e.nativeEvent.layout.width) : undefined}
accessibilityElementsHidden={copy === 1}
importantForAccessibility={copy === 1 ? 'no-hide-descendants' : 'auto'}
>
{topics.map((t) => (
<Chip
key={t.id}
topic={t}
on={selected.includes(t.id)}
tint={tintFor(t.id)}
onPress={() => onToggle(t.id)}
/>
))}
</View>
);
// With reduced motion the row holds still and scrolls by hand instead.
if (still) {
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scroll}
>
{chips(0)}
</ScrollView>
);
}
return (
<View style={styles.rowClip}>
<Animated.View style={[styles.track, drift]}>
{chips(0)}
{chips(1)}
</Animated.View>
</View>
);
}
function Chip({
topic,
on,
tint,
onPress,
}: {
topic: Topic;
on: boolean;
tint: string;
onPress: () => void;
}) {
const { colors, radius, font } = useKinetikTheme();
const reduced = useReduceMotion();
const pop = useSharedValue(0);
const style = useAnimatedStyle(() => ({ transform: [{ scale: 1 + pop.get() * 0.1 }] }));
return (
<Pressable
accessibilityRole="checkbox"
accessibilityLabel={topic.label}
accessibilityState={{ checked: on }}
onPress={() => {
if (!reduced) {
pop.set(withSequence(withTiming(1, timing('quick')), withSpring(0, spring('bouncy'))));
}
onPress();
}}
>
<Animated.View
style={[
styles.chip,
{
borderRadius: radius.pill,
backgroundColor: on ? withAlpha(tint, 0.28) : colors.surface,
// No shadow: dozens of chips move every frame, and a border is far cheaper.
borderColor: on ? withAlpha(tint, 0.6) : colors.borderStrong,
},
style,
]}
>
{topic.emoji ? <Text style={{ fontSize: font.size.lg }}>{topic.emoji}</Text> : null}
<Text
style={{ color: colors.text, fontSize: font.size.md + 1, fontWeight: font.weight.medium }}
>
{topic.label}
</Text>
</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create({
panel: { overflow: 'hidden' },
rowClip: { overflow: 'hidden', paddingVertical: 4 },
track: { flexDirection: 'row', alignSelf: 'flex-start' },
copy: { flexDirection: 'row', gap: 10, paddingRight: 10 },
scroll: { paddingHorizontal: 16, paddingVertical: 4 },
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
minHeight: minTouchTarget + 4,
paddingHorizontal: 18,
borderWidth: 1,
},
edge: { position: 'absolute', top: 0, bottom: 0, width: 40 },
edgeLeft: { left: 0 },
edgeRight: { right: 0 },
submit: { minHeight: 60, alignItems: 'center', justifyContent: 'center' },
});Installation
npx kinetik-ui add topic-pickerpnpm dlx kinetik-ui add topic-pickerbunx kinetik-ui add topic-pickeryarn dlx kinetik-ui add topic-pickerThe 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/topic-picker.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/topic-picker.tsx
Usage
import { TopicPicker } from '@/components/kinetik/topic-picker';
<TopicPicker
topics={[
{ id: 'basketball', label: 'Basketball', emoji: '🏀' },
{ id: 'fruits', label: 'Fruits', emoji: '🍋' },
{ id: 'weather', label: 'Weather', emoji: '☔' },
]}
selected={interests}
onChange={setInterests}
minimum={3}
submit={{ label: 'Submit', onPress: saveInterests }}
/>;When to use
- Picking interests during onboarding, from roughly nine to thirty short topics.
When not to use
- Choices people must read carefully: moving targets are harder to read. Use a static grid.
- A single choice: use radios or Choice Cards.
Notes
- Topics are dealt across
rowsin turn; rows drift in opposite directions at slightly different speeds. - With reduced motion the rows stop drifting and scroll by hand instead.
- Picked chips take a soft colour from the theme accents, so the set stays on palette.
Props
TopicPickerProps
| Prop | Type | Default | Description |
|---|---|---|---|
topicsrequired | readonly Topic[] | — | |
selectedrequired | readonly string[] | — | Ids of the picked topics. |
onChangerequired | (selected: string[]) => void | — | |
title | string | 'Select your topics' | |
subtitle | string | 'Choose topics that you are interested in' | |
rows | number | 3 | Rows of drifting chips. Topics are dealt across them in turn. Defaults to 3. |
speed | number | 22 | Drift speed in pt per second. Defaults to 22. |
submit | { label: string; onPress: (selected: string[]) => void } | — | The button under the rows. Disabled until `minimum` topics are picked. |
minimum | number | 1 | Defaults to 1. |
haptics | boolean | true | A tick on each pick. Defaults to true. |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- Each row is one looping UI-thread transform; chips only re-render when picked.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.29× the system Settings app on the same emulator (indicative only) · keeps drawing at rest · 159 MB app memory. Animating at rest: the rows drift continuously. Chips carry no shadow, which took frames from 28 to 22 ms here.
Real-device measurement pending.
Accessibility
- Reduced motion
- Rows hold still and scroll by hand instead of drifting; chips do not pop.
- Screen readers
- Each topic is a checkbox with its checked state; copies used for the loop are hidden. The submit button is disabled until enough are picked.
- Touch target
- Chips are 48 pt tall; the submit button is 60 pt.
Source files
components/kinetik/topic-picker.tsx
import { useEffect, useState } from 'react';
import {
Pressable,
ScrollView,
StyleSheet,
Text,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated';
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, withAlpha } from '../../lib/kinetik/tokens/tokens';
export type Topic = {
id: string;
label: string;
/** An emoji or short glyph before the label. */
emoji?: string;
};
export type TopicPickerProps = {
topics: readonly Topic[];
/** Ids of the picked topics. */
selected: readonly string[];
onChange: (selected: string[]) => void;
title?: string;
subtitle?: string;
/** Rows of drifting chips. Topics are dealt across them in turn. Defaults to 3. */
rows?: number;
/** Drift speed in pt per second. Defaults to 22. */
speed?: number;
/** The button under the rows. Disabled until `minimum` topics are picked. */
submit?: { label: string; onPress: (selected: string[]) => void };
/** Defaults to 1. */
minimum?: number;
/** A tick on each pick. Defaults to true. */
haptics?: boolean;
style?: StyleProp<ViewStyle>;
};
/**
* Topic chips drifting past in rows that run in opposite directions, fading
* out at the edges. Picking one pops it and washes it in a soft colour; the
* button underneath wakes up once enough are picked.
*/
export function TopicPicker({
topics,
selected,
onChange,
title = 'Select your topics',
subtitle = 'Choose topics that you are interested in',
rows = 3,
speed = 22,
submit,
minimum = 1,
haptics = true,
style,
}: TopicPickerProps) {
const { colors, radius, space, font, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const lines = Array.from({ length: rows }, (_, r) => topics.filter((_, i) => i % rows === r));
const tints = [colors.azure, colors.warning, colors.danger, colors.success, colors.sky];
const ready = selected.length >= minimum;
const toggle = (id: string) => {
haptic('tick');
onChange(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]);
};
const fade = withAlpha(colors.surfaceRaised, 0);
return (
<View
style={[
styles.panel,
{
backgroundColor: colors.surfaceRaised,
borderRadius: radius.xl + 8,
paddingVertical: space[6],
gap: space[6],
boxShadow: shadow.lg,
},
style,
]}
>
<View style={{ alignItems: 'center', gap: space[1], paddingHorizontal: space[5] }}>
<Text
accessibilityRole="header"
style={{
color: colors.text,
fontSize: font.size['2xl'],
fontWeight: font.weight.bold,
textAlign: 'center',
}}
>
{title}
</Text>
<Text style={{ color: colors.textMuted, fontSize: font.size.md, textAlign: 'center' }}>
{subtitle}
</Text>
</View>
<View style={{ gap: space[3] }}>
{lines.map((line, r) => (
<Row
key={r}
topics={line}
direction={r % 2 === 0 ? 'left' : 'right'}
speed={speed * (1 + r * 0.18)}
still={reduced}
selected={selected}
tintFor={(id) => tints[topics.findIndex((t) => t.id === id) % tints.length]!}
onToggle={toggle}
/>
))}
<View
pointerEvents="none"
style={[
styles.edge,
styles.edgeLeft,
gradient(`linear-gradient(90deg, ${colors.surfaceRaised} 0%, ${fade} 100%)`),
]}
/>
<View
pointerEvents="none"
style={[
styles.edge,
styles.edgeRight,
gradient(`linear-gradient(270deg, ${colors.surfaceRaised} 0%, ${fade} 100%)`),
]}
/>
</View>
{submit ? (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: !ready }}
accessibilityHint={`${selected.length} selected`}
disabled={!ready}
onPress={() => submit.onPress([...selected])}
style={({ pressed }) => [
styles.submit,
{
marginHorizontal: space[5],
borderRadius: radius.pill,
backgroundColor: ready ? colors.azureSoft : colors.surfaceSunken,
boxShadow: ready ? glow(colors.azure) : undefined,
transform: [{ scale: pressed ? 0.98 : 1 }],
},
]}
>
<Text
style={{
color: ready ? colors.text : colors.textSubtle,
fontSize: font.size.md,
fontWeight: font.weight.bold,
letterSpacing: font.tracking.wide,
}}
>
{submit.label.toUpperCase()}
</Text>
</Pressable>
) : null}
</View>
);
}
function Row({
topics,
direction,
speed,
still,
selected,
tintFor,
onToggle,
}: {
topics: readonly Topic[];
direction: 'left' | 'right';
speed: number;
still: boolean;
selected: readonly string[];
tintFor: (id: string) => string;
onToggle: (id: string) => void;
}) {
const [span, setSpan] = useState(0);
const x = useSharedValue(0);
useEffect(() => {
if (still || span <= 0) return;
const from = direction === 'left' ? 0 : -span;
const to = direction === 'left' ? -span : 0;
x.set(from);
x.set(
withRepeat(withTiming(to, { duration: (span / speed) * 1000, easing: Easing.linear }), -1),
);
}, [span, speed, direction, still, x]);
const drift = useAnimatedStyle(() => ({ transform: [{ translateX: x.get() }] }));
const chips = (copy: number) => (
<View
style={styles.copy}
onLayout={copy === 0 ? (e) => setSpan(e.nativeEvent.layout.width) : undefined}
accessibilityElementsHidden={copy === 1}
importantForAccessibility={copy === 1 ? 'no-hide-descendants' : 'auto'}
>
{topics.map((t) => (
<Chip
key={t.id}
topic={t}
on={selected.includes(t.id)}
tint={tintFor(t.id)}
onPress={() => onToggle(t.id)}
/>
))}
</View>
);
// With reduced motion the row holds still and scrolls by hand instead.
if (still) {
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scroll}
>
{chips(0)}
</ScrollView>
);
}
return (
<View style={styles.rowClip}>
<Animated.View style={[styles.track, drift]}>
{chips(0)}
{chips(1)}
</Animated.View>
</View>
);
}
function Chip({
topic,
on,
tint,
onPress,
}: {
topic: Topic;
on: boolean;
tint: string;
onPress: () => void;
}) {
const { colors, radius, font } = useKinetikTheme();
const reduced = useReduceMotion();
const pop = useSharedValue(0);
const style = useAnimatedStyle(() => ({ transform: [{ scale: 1 + pop.get() * 0.1 }] }));
return (
<Pressable
accessibilityRole="checkbox"
accessibilityLabel={topic.label}
accessibilityState={{ checked: on }}
onPress={() => {
if (!reduced) {
pop.set(withSequence(withTiming(1, timing('quick')), withSpring(0, spring('bouncy'))));
}
onPress();
}}
>
<Animated.View
style={[
styles.chip,
{
borderRadius: radius.pill,
backgroundColor: on ? withAlpha(tint, 0.28) : colors.surface,
// No shadow: dozens of chips move every frame, and a border is far cheaper.
borderColor: on ? withAlpha(tint, 0.6) : colors.borderStrong,
},
style,
]}
>
{topic.emoji ? <Text style={{ fontSize: font.size.lg }}>{topic.emoji}</Text> : null}
<Text
style={{ color: colors.text, fontSize: font.size.md + 1, fontWeight: font.weight.medium }}
>
{topic.label}
</Text>
</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create({
panel: { overflow: 'hidden' },
rowClip: { overflow: 'hidden', paddingVertical: 4 },
track: { flexDirection: 'row', alignSelf: 'flex-start' },
copy: { flexDirection: 'row', gap: 10, paddingRight: 10 },
scroll: { paddingHorizontal: 16, paddingVertical: 4 },
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
minHeight: minTouchTarget + 4,
paddingHorizontal: 18,
borderWidth: 1,
},
edge: { position: 'absolute', top: 0, bottom: 0, width: 40 },
edgeLeft: { left: 0 },
edgeRight: { right: 0 },
submit: { minHeight: 60, alignItems: 'center', justifyContent: 'center' },
});