Swatch Picker
Colour swatches with a ring that glides to your pick, a small squeeze and a check in readable ink.
import { useEffect, useRef, useState } from 'react';
import { Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
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 } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget, palettes } from '../../lib/kinetik/tokens/tokens';
export type Swatch = {
/** Any colour React Native accepts; `#RRGGBB` also picks a readable check colour. */
value: string;
/** Read by screen readers, e.g. "Sage". */
label: string;
};
export type SwatchPickerProps = {
swatches: readonly Swatch[];
value: string;
onValueChange: (value: string) => void;
/** Names the group, e.g. "Accent colour". */
accessibilityLabel: string;
/** Diameter of each swatch. Defaults to 32. */
size?: number;
/** A tick when the colour changes. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const CELL = minTouchTarget;
const GAP = 3;
const RING = 2;
/** True when dark ink reads better than light ink on the colour. */
export function isLight(color: string): boolean {
const m = /^#([0-9a-f]{6})$/i.exec(color);
if (!m?.[1]) return false;
const n = parseInt(m[1], 16);
const lin = (c: number) => {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
const l = 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255);
return l > 0.4;
}
/**
* A row of colour swatches. A ring in the chosen colour glides from swatch
* to swatch, the swatch under it gives a small squeeze, and a check pops in
* whose ink is picked to stay readable on that colour.
*/
export function SwatchPicker({
swatches,
value,
onValueChange,
accessibilityLabel,
size = 32,
haptics = true,
testID,
style,
}: SwatchPickerProps) {
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const [spots, setSpots] = useState<Record<string, { x: number; y: number }>>({});
const x = useSharedValue(0);
const y = useSharedValue(0);
const placed = useRef(false);
const spot = spots[value];
const ring = size + (GAP + RING) * 2;
useEffect(() => {
if (!spot) return;
const snap = reduced || !placed.current;
placed.current = true;
x.set(snap ? spot.x : withSpring(spot.x, spring('smooth')));
y.set(snap ? spot.y : withSpring(spot.y, spring('smooth')));
}, [spot, reduced, x, y]);
const ringStyle = useAnimatedStyle(() => ({
opacity: spot ? 1 : 0,
transform: [{ translateX: x.get() }, { translateY: y.get() }],
}));
return (
<View
testID={testID}
accessibilityRole="radiogroup"
accessibilityLabel={accessibilityLabel}
style={[styles.wrap, style]}
>
<Animated.View
pointerEvents="none"
style={[
styles.ring,
{
width: ring,
height: ring,
borderRadius: ring / 2,
borderColor: value,
left: (CELL - ring) / 2,
top: (CELL - ring) / 2,
},
ringStyle,
]}
/>
{swatches.map((s) => (
<Dot
key={s.value}
swatch={s}
size={size}
selected={s.value === value}
reduced={reduced}
onPress={() => {
if (s.value === value) return;
haptic('tick');
onValueChange(s.value);
}}
onPlace={(px, py) =>
setSpots((m) => {
const prev = m[s.value];
return prev && prev.x === px && prev.y === py
? m
: { ...m, [s.value]: { x: px, y: py } };
})
}
/>
))}
</View>
);
}
type DotProps = {
swatch: Swatch;
size: number;
selected: boolean;
reduced: boolean;
onPress: () => void;
onPlace: (x: number, y: number) => void;
};
function Dot({ swatch, size, selected, reduced, onPress, onPlace }: DotProps) {
const { colors } = useKinetikTheme();
const on = useSharedValue(selected ? 1 : 0);
const squeeze = useSharedValue(1);
useEffect(() => {
on.set(withSpring(selected ? 1 : 0, spring('bouncy', reduced)));
if (selected && !reduced) {
squeeze.set(
withSequence(withTiming(0.86, { duration: 90 }), withSpring(1, spring('bouncy'))),
);
}
}, [selected, reduced, on, squeeze]);
const body = useAnimatedStyle(() => ({ transform: [{ scale: squeeze.get() }] }));
const check = useAnimatedStyle(() => ({
opacity: Math.min(1, on.get() * 1.5),
transform: [{ scale: 0.4 + on.get() * 0.6 }, { rotate: '-45deg' }],
}));
const ink = isLight(swatch.value) ? palettes.light.text : palettes.dark.text;
return (
<Pressable
onPress={onPress}
onLayout={(e) => onPlace(e.nativeEvent.layout.x, e.nativeEvent.layout.y)}
accessibilityRole="radio"
accessibilityLabel={swatch.label}
accessibilityState={{ checked: selected }}
style={styles.cell}
>
<Animated.View
style={[
styles.dot,
{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: swatch.value,
borderColor: colors.border,
},
body,
]}
>
<Animated.View
style={[
styles.check,
{ borderColor: ink, width: size * 0.42, height: size * 0.24 },
check,
]}
/>
</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create({
wrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4 },
ring: { position: 'absolute', borderWidth: RING },
cell: { width: CELL, height: CELL, alignItems: 'center', justifyContent: 'center' },
dot: { borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center' },
check: { borderLeftWidth: 2.2, borderBottomWidth: 2.2, marginTop: -2 },
});Installation
npx kinetik-ui add swatch-pickerpnpm dlx kinetik-ui add swatch-pickerbunx kinetik-ui add swatch-pickeryarn dlx kinetik-ui add swatch-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/swatch-picker.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/swatch-picker.tsx
Usage
import { SwatchPicker } from '@/components/kinetik/swatch-picker';
<SwatchPicker
accessibilityLabel="Accent colour"
swatches={[
{ value: '#2B6BEF', label: 'Cobalt' },
{ value: '#3BA58B', label: 'Sea glass' },
{ value: '#F2C14E', label: 'Butter' },
]}
value={accent}
onValueChange={setAccent}
/>;When to use
- Picking from a fixed set of colours: a label colour, a theme accent, a product finish.
- Small sets where seeing every option at once matters more than precision.
When not to use
- Any colour at all. Use a full colour picker with hue and lightness.
- Choices that are not colours. Use Glide Radio or Choice Cards.
Notes
- Every swatch needs a
label: screen readers read the name, never the hex value. - With
#RRGGBBvalues the check is drawn in dark or light ink, whichever reads better on that colour. - Each swatch sits in a 44 pt cell, so
sizecan go down to 24 without shrinking the target. - With reduced motion the ring jumps and the swatch does not squeeze.
Props
SwatchPickerProps
| Prop | Type | Default | Description |
|---|---|---|---|
swatchesrequired | readonly Swatch[] | — | |
valuerequired | string | — | |
onValueChangerequired | (value: string) => void | — | |
accessibilityLabelrequired | string | — | Names the group, e.g. "Accent colour". |
size | number | 32 | Diameter of each swatch. Defaults to 32. |
haptics | boolean | true | A tick when the colour changes. Defaults to true. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- Plain views; one ring on two shared values and two per swatch for the squeeze and check. Nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 0.94× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 145 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- The ring jumps to the new swatch and the check appears without a spring.
- Screen readers
- A radio group named by accessibilityLabel; each swatch is a radio read by its label.
- Touch target
- Each swatch is the centre of a 44 by 44 pt cell.
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/swatch-picker.tsx
import { useEffect, useRef, useState } from 'react';
import { Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
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 } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget, palettes } from '../../lib/kinetik/tokens/tokens';
export type Swatch = {
/** Any colour React Native accepts; `#RRGGBB` also picks a readable check colour. */
value: string;
/** Read by screen readers, e.g. "Sage". */
label: string;
};
export type SwatchPickerProps = {
swatches: readonly Swatch[];
value: string;
onValueChange: (value: string) => void;
/** Names the group, e.g. "Accent colour". */
accessibilityLabel: string;
/** Diameter of each swatch. Defaults to 32. */
size?: number;
/** A tick when the colour changes. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const CELL = minTouchTarget;
const GAP = 3;
const RING = 2;
/** True when dark ink reads better than light ink on the colour. */
export function isLight(color: string): boolean {
const m = /^#([0-9a-f]{6})$/i.exec(color);
if (!m?.[1]) return false;
const n = parseInt(m[1], 16);
const lin = (c: number) => {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
const l = 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255);
return l > 0.4;
}
/**
* A row of colour swatches. A ring in the chosen colour glides from swatch
* to swatch, the swatch under it gives a small squeeze, and a check pops in
* whose ink is picked to stay readable on that colour.
*/
export function SwatchPicker({
swatches,
value,
onValueChange,
accessibilityLabel,
size = 32,
haptics = true,
testID,
style,
}: SwatchPickerProps) {
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const [spots, setSpots] = useState<Record<string, { x: number; y: number }>>({});
const x = useSharedValue(0);
const y = useSharedValue(0);
const placed = useRef(false);
const spot = spots[value];
const ring = size + (GAP + RING) * 2;
useEffect(() => {
if (!spot) return;
const snap = reduced || !placed.current;
placed.current = true;
x.set(snap ? spot.x : withSpring(spot.x, spring('smooth')));
y.set(snap ? spot.y : withSpring(spot.y, spring('smooth')));
}, [spot, reduced, x, y]);
const ringStyle = useAnimatedStyle(() => ({
opacity: spot ? 1 : 0,
transform: [{ translateX: x.get() }, { translateY: y.get() }],
}));
return (
<View
testID={testID}
accessibilityRole="radiogroup"
accessibilityLabel={accessibilityLabel}
style={[styles.wrap, style]}
>
<Animated.View
pointerEvents="none"
style={[
styles.ring,
{
width: ring,
height: ring,
borderRadius: ring / 2,
borderColor: value,
left: (CELL - ring) / 2,
top: (CELL - ring) / 2,
},
ringStyle,
]}
/>
{swatches.map((s) => (
<Dot
key={s.value}
swatch={s}
size={size}
selected={s.value === value}
reduced={reduced}
onPress={() => {
if (s.value === value) return;
haptic('tick');
onValueChange(s.value);
}}
onPlace={(px, py) =>
setSpots((m) => {
const prev = m[s.value];
return prev && prev.x === px && prev.y === py
? m
: { ...m, [s.value]: { x: px, y: py } };
})
}
/>
))}
</View>
);
}
type DotProps = {
swatch: Swatch;
size: number;
selected: boolean;
reduced: boolean;
onPress: () => void;
onPlace: (x: number, y: number) => void;
};
function Dot({ swatch, size, selected, reduced, onPress, onPlace }: DotProps) {
const { colors } = useKinetikTheme();
const on = useSharedValue(selected ? 1 : 0);
const squeeze = useSharedValue(1);
useEffect(() => {
on.set(withSpring(selected ? 1 : 0, spring('bouncy', reduced)));
if (selected && !reduced) {
squeeze.set(
withSequence(withTiming(0.86, { duration: 90 }), withSpring(1, spring('bouncy'))),
);
}
}, [selected, reduced, on, squeeze]);
const body = useAnimatedStyle(() => ({ transform: [{ scale: squeeze.get() }] }));
const check = useAnimatedStyle(() => ({
opacity: Math.min(1, on.get() * 1.5),
transform: [{ scale: 0.4 + on.get() * 0.6 }, { rotate: '-45deg' }],
}));
const ink = isLight(swatch.value) ? palettes.light.text : palettes.dark.text;
return (
<Pressable
onPress={onPress}
onLayout={(e) => onPlace(e.nativeEvent.layout.x, e.nativeEvent.layout.y)}
accessibilityRole="radio"
accessibilityLabel={swatch.label}
accessibilityState={{ checked: selected }}
style={styles.cell}
>
<Animated.View
style={[
styles.dot,
{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: swatch.value,
borderColor: colors.border,
},
body,
]}
>
<Animated.View
style={[
styles.check,
{ borderColor: ink, width: size * 0.42, height: size * 0.24 },
check,
]}
/>
</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create({
wrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4 },
ring: { position: 'absolute', borderWidth: RING },
cell: { width: CELL, height: CELL, alignItems: 'center', justifyContent: 'center' },
dot: { borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center' },
check: { borderLeftWidth: 2.2, borderBottomWidth: 2.2, marginTop: -2 },
});