# Filter Chips

> Chips that change shape when picked: a check slides in, the chip grows to fit and its outline turns to a tint.

- Category: Inputs
- Version: 1.0.0
- Platforms: iOS, Android, web
- Dependencies: `react-native-reanimated`, `react-native-worklets`
- Requires: React Native New Architecture (Reanimated 4)

## Install

```bash
npx kinetik-ui add filter-chips
```

With the shadcn CLI:

```bash
npx shadcn@latest add https://kinetik-ui.dev/r/filter-chips.json
```

## Usage

```tsx
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.
- `count` is 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.

## FilterChipsProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `options` | `readonly FilterChipOption[]` |  |  |
| `value` | `readonly string[]` |  | Selected values. |
| `onValueChange` | `(value: string[]) => void` |  |  |
| `accessibilityLabel` | `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>` |  |  |

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

## Performance

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.

## Source

`components/kinetik/filter-chips.tsx`

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