Filter Chips
Chips that change shape when picked: a check slides in, the chip grows to fit and its outline turns to a tint.
import { useEffect } from 'react';
import { Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import type { Palette } from '../../lib/kinetik/tokens/tokens';
export type FilterChipOption = {
value: string;
label: string;
/** A count after the label, e.g. the number of results. */
count?: number;
};
export type FilterChipsProps = {
options: readonly FilterChipOption[];
/** Selected values. */
value: readonly string[];
onValueChange: (value: string[]) => void;
/** Pick any number of chips, or exactly one. Defaults to true. */
multiple?: boolean;
/** Names the group for screen readers, e.g. "Filter by type". */
accessibilityLabel: string;
/** A tick on each change. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const HEIGHT = 40;
const TARGET = 48;
/** Room the check mark takes when it slides in. */
const CHECK_W = 18;
/**
* Filter chips that change shape when picked: a check mark slides in at the
* front and pushes the label over, the chip grows to fit, and its outline
* turns into a tinted fill. Neighbours make room as it grows.
*/
export function FilterChips({
options,
value,
onValueChange,
multiple = true,
accessibilityLabel,
haptics = true,
testID,
style,
}: FilterChipsProps) {
const { colors } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const toggle = (v: string) => {
haptic('tick');
if (!multiple) {
onValueChange([v]);
return;
}
onValueChange(value.includes(v) ? value.filter((x) => x !== v) : [...value, v]);
};
return (
<View
testID={testID}
accessibilityRole={multiple ? undefined : 'radiogroup'}
accessibilityLabel={accessibilityLabel}
style={[styles.wrap, style]}
>
{options.map((option) => (
<Chip
key={option.value}
option={option}
selected={value.includes(option.value)}
multiple={multiple}
onPress={() => toggle(option.value)}
reduced={reduced}
colors={colors}
/>
))}
</View>
);
}
type ChipProps = {
option: FilterChipOption;
selected: boolean;
multiple: boolean;
onPress: () => void;
reduced: boolean;
colors: Palette;
};
function Chip({ option, selected, multiple, onPress, reduced, colors }: ChipProps) {
const { font, radius } = useKinetikTheme();
const on = useSharedValue(selected ? 1 : 0);
const press = useSharedValue(0);
useEffect(() => {
on.set(withSpring(selected ? 1 : 0, spring('snappy', reduced)));
}, [selected, reduced, on]);
const shell = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(on.get(), [0, 1], [colors.surface, colors.azureSoft]),
borderColor: interpolateColor(on.get(), [0, 1], [colors.border, colors.azure]),
transform: [{ scale: 1 - press.get() * 0.05 }],
}));
const slot = useAnimatedStyle(() => ({
width: CHECK_W * Math.max(0, on.get()),
opacity: Math.max(0, on.get() * 1.4 - 0.4),
}));
const check = useAnimatedStyle(() => ({
transform: [{ rotate: '45deg' }, { scale: 0.4 + Math.max(0, on.get()) * 0.6 }],
}));
const label = useAnimatedStyle(() => ({
color: interpolateColor(on.get(), [0, 1], [colors.textMuted, colors.text]),
}));
return (
<Pressable
onPress={onPress}
onPressIn={() => press.set(withSpring(1, spring('snappy', reduced)))}
onPressOut={() => press.set(withSpring(0, spring('bouncy', reduced)))}
accessibilityRole={multiple ? 'checkbox' : 'radio'}
accessibilityLabel={
option.count === undefined ? option.label : `${option.label}, ${option.count}`
}
accessibilityState={multiple ? { checked: selected } : { selected }}
style={styles.hit}
>
<Animated.View style={[styles.chip, { borderRadius: radius.pill }, shell]}>
<Animated.View style={[styles.slot, slot]}>
<Animated.View style={[styles.check, { borderColor: colors.azure }, check]} />
</Animated.View>
<Animated.Text
numberOfLines={1}
style={[{ fontSize: font.size.sm, fontWeight: font.weight.medium }, label]}
>
{option.label}
</Animated.Text>
{option.count !== undefined ? (
<Animated.Text
style={{
color: colors.textSubtle,
fontSize: font.size.xs,
fontWeight: font.weight.medium,
fontVariant: ['tabular-nums'],
marginLeft: 6,
}}
>
{option.count}
</Animated.Text>
) : null}
</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create({
// Rows sit flush: the targets' own padding keeps the chips 8 pt apart.
wrap: { flexDirection: 'row', flexWrap: 'wrap', columnGap: 8, rowGap: 0 },
// The chip is drawn 40 pt tall inside a 48 pt target.
hit: { height: TARGET, justifyContent: 'center' },
chip: {
height: HEIGHT,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
borderWidth: 1,
},
slot: { height: HEIGHT, justifyContent: 'center', overflow: 'hidden' },
// A classic tick: the bottom and right edges of a narrow box, turned 45°.
check: {
width: 6,
height: 11,
marginLeft: 2,
marginTop: -3,
borderBottomWidth: 2,
borderRightWidth: 2,
},
});Installation
npx kinetik-ui add filter-chipspnpm dlx kinetik-ui add filter-chipsbunx kinetik-ui add filter-chipsyarn dlx kinetik-ui add filter-chipsThe 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/filter-chips.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/filter-chips.tsx
Usage
import { FilterChips } from '@/components/kinetik/filter-chips';
<FilterChips
accessibilityLabel="Filter by type"
options={[
{ value: 'photos', label: 'Photos', count: 48 },
{ value: 'videos', label: 'Videos', count: 6 },
]}
value={types}
onValueChange={setTypes}
/>;Set multiple={false} for a single choice: the chips become radios and a press always leaves exactly one picked.
When to use
- Narrowing a list or a grid by a few facets: type, place, status, size.
- Picking interests or tags during onboarding.
When not to use
- More than a dozen options. Use a searchable list.
- Switching between views of the same content. Use tabs or a segmented control.
Notes
- The chips wrap onto as many lines as they need. A picked chip grows by the width of its check, and the chips after it make room.
countis shown after the label in a quieter colour and read after it.- With reduced motion the chips change colour and show the check without growing.
Props
FilterChipsProps
| Prop | Type | Default | Description |
|---|---|---|---|
optionsrequired | readonly FilterChipOption[] | — | |
valuerequired | readonly string[] | — | Selected values. |
onValueChangerequired | (value: string[]) => void | — | |
accessibilityLabelrequired | string | — | Names the group for screen readers, e.g. "Filter by type". |
multiple | boolean | true | Pick any number of chips, or exactly one. Defaults to true. |
haptics | boolean | true | A tick on each change. Defaults to true. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- One shared value per chip. A picked chip's width animates, so the row lays out again for the length of the spring; nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 0.90× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 152 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- Chips change colour and show the check at once, without growing.
- Screen readers
- Checkboxes with a checked state, or radios with a selected state when `multiple` is false. Counts are read after the label.
- Touch target
- 40 pt chips drawn inside 48 pt targets.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/filter-chips.tsx
import { useEffect } from 'react';
import { Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import type { Palette } from '../../lib/kinetik/tokens/tokens';
export type FilterChipOption = {
value: string;
label: string;
/** A count after the label, e.g. the number of results. */
count?: number;
};
export type FilterChipsProps = {
options: readonly FilterChipOption[];
/** Selected values. */
value: readonly string[];
onValueChange: (value: string[]) => void;
/** Pick any number of chips, or exactly one. Defaults to true. */
multiple?: boolean;
/** Names the group for screen readers, e.g. "Filter by type". */
accessibilityLabel: string;
/** A tick on each change. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const HEIGHT = 40;
const TARGET = 48;
/** Room the check mark takes when it slides in. */
const CHECK_W = 18;
/**
* Filter chips that change shape when picked: a check mark slides in at the
* front and pushes the label over, the chip grows to fit, and its outline
* turns into a tinted fill. Neighbours make room as it grows.
*/
export function FilterChips({
options,
value,
onValueChange,
multiple = true,
accessibilityLabel,
haptics = true,
testID,
style,
}: FilterChipsProps) {
const { colors } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const toggle = (v: string) => {
haptic('tick');
if (!multiple) {
onValueChange([v]);
return;
}
onValueChange(value.includes(v) ? value.filter((x) => x !== v) : [...value, v]);
};
return (
<View
testID={testID}
accessibilityRole={multiple ? undefined : 'radiogroup'}
accessibilityLabel={accessibilityLabel}
style={[styles.wrap, style]}
>
{options.map((option) => (
<Chip
key={option.value}
option={option}
selected={value.includes(option.value)}
multiple={multiple}
onPress={() => toggle(option.value)}
reduced={reduced}
colors={colors}
/>
))}
</View>
);
}
type ChipProps = {
option: FilterChipOption;
selected: boolean;
multiple: boolean;
onPress: () => void;
reduced: boolean;
colors: Palette;
};
function Chip({ option, selected, multiple, onPress, reduced, colors }: ChipProps) {
const { font, radius } = useKinetikTheme();
const on = useSharedValue(selected ? 1 : 0);
const press = useSharedValue(0);
useEffect(() => {
on.set(withSpring(selected ? 1 : 0, spring('snappy', reduced)));
}, [selected, reduced, on]);
const shell = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(on.get(), [0, 1], [colors.surface, colors.azureSoft]),
borderColor: interpolateColor(on.get(), [0, 1], [colors.border, colors.azure]),
transform: [{ scale: 1 - press.get() * 0.05 }],
}));
const slot = useAnimatedStyle(() => ({
width: CHECK_W * Math.max(0, on.get()),
opacity: Math.max(0, on.get() * 1.4 - 0.4),
}));
const check = useAnimatedStyle(() => ({
transform: [{ rotate: '45deg' }, { scale: 0.4 + Math.max(0, on.get()) * 0.6 }],
}));
const label = useAnimatedStyle(() => ({
color: interpolateColor(on.get(), [0, 1], [colors.textMuted, colors.text]),
}));
return (
<Pressable
onPress={onPress}
onPressIn={() => press.set(withSpring(1, spring('snappy', reduced)))}
onPressOut={() => press.set(withSpring(0, spring('bouncy', reduced)))}
accessibilityRole={multiple ? 'checkbox' : 'radio'}
accessibilityLabel={
option.count === undefined ? option.label : `${option.label}, ${option.count}`
}
accessibilityState={multiple ? { checked: selected } : { selected }}
style={styles.hit}
>
<Animated.View style={[styles.chip, { borderRadius: radius.pill }, shell]}>
<Animated.View style={[styles.slot, slot]}>
<Animated.View style={[styles.check, { borderColor: colors.azure }, check]} />
</Animated.View>
<Animated.Text
numberOfLines={1}
style={[{ fontSize: font.size.sm, fontWeight: font.weight.medium }, label]}
>
{option.label}
</Animated.Text>
{option.count !== undefined ? (
<Animated.Text
style={{
color: colors.textSubtle,
fontSize: font.size.xs,
fontWeight: font.weight.medium,
fontVariant: ['tabular-nums'],
marginLeft: 6,
}}
>
{option.count}
</Animated.Text>
) : null}
</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create({
// Rows sit flush: the targets' own padding keeps the chips 8 pt apart.
wrap: { flexDirection: 'row', flexWrap: 'wrap', columnGap: 8, rowGap: 0 },
// The chip is drawn 40 pt tall inside a 48 pt target.
hit: { height: TARGET, justifyContent: 'center' },
chip: {
height: HEIGHT,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
borderWidth: 1,
},
slot: { height: HEIGHT, justifyContent: 'center', overflow: 'hidden' },
// A classic tick: the bottom and right edges of a narrow box, turned 45°.
check: {
width: 6,
height: 11,
marginLeft: 2,
marginTop: -3,
borderBottomWidth: 2,
borderRightWidth: 2,
},
});