Aspect Picker
Aspect-ratio frames drawn to scale; a soft square slides to the pick and its frame grows a touch.
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react';
import {
Pressable,
StyleSheet,
View,
type LayoutChangeEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, { 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 { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';
export type AspectOption = {
value: string;
/** Width divided by height, e.g. 1, 3 / 2 or 9 / 16. */
ratio: number;
/** Read by screen readers, e.g. "Square" or "Portrait 2 by 3". */
label: string;
};
export type AspectPickerAction = {
icon: ReactNode;
/** Accessible name, e.g. "More ratios". */
label: string;
onPress: () => void;
};
export type AspectPickerProps = {
value: string;
onChange: (value: string) => void;
/** Defaults to square, landscape 3:2 and portrait 2:3. */
options?: readonly AspectOption[];
/** Names the group for screen readers. Defaults to "Aspect ratio". */
label?: string;
/** A button after the ratios, e.g. more ratios. */
action?: AspectPickerAction;
disabled?: boolean;
/** A tick when the ratio changes. Defaults to true. */
haptics?: boolean;
style?: StyleProp<ViewStyle>;
};
const DEFAULT_OPTIONS: AspectOption[] = [
{ value: '1:1', ratio: 1, label: 'Square' },
{ value: '3:2', ratio: 3 / 2, label: 'Landscape 3 by 2' },
{ value: '2:3', ratio: 2 / 3, label: 'Portrait 2 by 3' },
];
/** The longest side of each ratio frame, in pt. */
const FRAME = 20;
/**
* A row of aspect-ratio frames drawn to scale. A soft square slides to the
* one you pick, and that frame brightens and grows a touch as it lands.
*/
export function AspectPicker({
value,
onChange,
options = DEFAULT_OPTIONS,
label = 'Aspect ratio',
action,
disabled = false,
haptics = true,
style,
}: AspectPickerProps) {
const { colors, radius, space } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const slots = useRef<Record<string, number>>({});
const [measured, setMeasured] = useState(0);
const x = useSharedValue(0);
const shown = useSharedValue(0);
const placed = useRef(false);
useEffect(() => {
const at = slots.current[value];
if (at === undefined) return;
shown.set(1);
if (!placed.current || reduced) {
x.set(at);
placed.current = true;
return;
}
x.set(withSpring(at, spring('smooth')));
}, [value, measured, reduced, x, shown]);
const onSlotLayout = (key: string) => (e: LayoutChangeEvent) => {
slots.current[key] = e.nativeEvent.layout.x;
setMeasured((m) => m + 1);
};
const highlight = useAnimatedStyle(() => ({
opacity: shown.get(),
transform: [{ translateX: x.get() }],
}));
return (
<View
style={[
styles.panel,
{
backgroundColor: colors.surfaceRaised,
borderRadius: radius.lg,
paddingHorizontal: space[3],
opacity: disabled ? 0.45 : 1,
},
style,
]}
>
<View accessibilityRole="radiogroup" accessibilityLabel={label} style={styles.row}>
<Animated.View
pointerEvents="none"
style={[
styles.highlight,
{ backgroundColor: withAlpha(colors.text, 0.12), borderRadius: radius.sm },
highlight,
]}
/>
{options.map((o, i) => (
<Fragment key={o.value}>
{i > 0 ? <View style={[styles.rule, { backgroundColor: colors.border }]} /> : null}
<Choice
option={o}
on={o.value === value}
disabled={disabled}
onLayout={onSlotLayout(o.value)}
onPress={() => {
haptic('tick');
onChange(o.value);
}}
/>
</Fragment>
))}
{action ? (
<>
<View style={[styles.rule, { backgroundColor: colors.border }]} />
<Pressable
accessibilityRole="button"
accessibilityLabel={action.label}
disabled={disabled}
onPress={action.onPress}
style={({ pressed }) => [styles.slot, { opacity: pressed ? 0.6 : 1 }]}
>
{action.icon}
</Pressable>
</>
) : null}
</View>
</View>
);
}
function Choice({
option,
on,
disabled,
onLayout,
onPress,
}: {
option: AspectOption;
on: boolean;
disabled: boolean;
onLayout: (e: LayoutChangeEvent) => void;
onPress: () => void;
}) {
const { colors } = useKinetikTheme();
const reduced = useReduceMotion();
const lift = useSharedValue(on ? 1 : 0);
useEffect(() => {
lift.set(reduced ? (on ? 1 : 0) : withSpring(on ? 1 : 0, spring('bouncy')));
}, [on, reduced, lift]);
const frame = useAnimatedStyle(() => ({ transform: [{ scale: 1 + lift.get() * 0.12 }] }));
const wide = option.ratio >= 1;
const w = wide ? FRAME : FRAME * option.ratio;
const h = wide ? FRAME / option.ratio : FRAME;
return (
<Pressable
accessibilityRole="radio"
accessibilityLabel={option.label}
accessibilityState={{ checked: on, disabled }}
disabled={disabled}
onLayout={onLayout}
onPress={on ? undefined : onPress}
style={styles.slot}
>
<Animated.View
style={[
styles.frame,
{ width: w, height: h, borderColor: on ? colors.text : colors.textMuted },
frame,
]}
/>
</Pressable>
);
}
const styles = StyleSheet.create({
panel: { minHeight: 56, justifyContent: 'center' },
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
highlight: { position: 'absolute', left: 0, width: minTouchTarget, height: minTouchTarget },
slot: {
width: minTouchTarget,
height: minTouchTarget,
alignItems: 'center',
justifyContent: 'center',
},
frame: { borderWidth: 1.75, borderRadius: 3 },
rule: { width: StyleSheet.hairlineWidth, height: 16 },
});Installation
npx kinetik-ui add aspect-pickerpnpm dlx kinetik-ui add aspect-pickerbunx kinetik-ui add aspect-pickeryarn dlx kinetik-ui add aspect-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/aspect-picker.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/aspect-picker.tsx
Usage
import { AspectPicker } from '@/components/kinetik/aspect-picker';
<AspectPicker
value={ratio}
onChange={setRatio}
options={[
{ value: '1:1', ratio: 1, label: 'Square' },
{ value: '16:9', ratio: 16 / 9, label: 'Widescreen' },
{ value: '9:16', ratio: 9 / 16, label: 'Story' },
]}
action={{ icon: <MoreIcon />, label: 'More ratios', onPress: openRatios }}
/>;When to use
- Choosing a crop or canvas shape in a photo, video or design editor.
When not to use
- More than four or five ratios in a row: show the common ones and put the rest behind the action.
Notes
- Frames are drawn to scale from
ratio, so any ratio works without icons. - Defaults to square, 3:2 and 2:3.
Props
AspectPickerProps
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | string | — | |
onChangerequired | (value: string) => void | — | |
options | readonly AspectOption[] | DEFAULT_OPTIONS | Defaults to square, landscape 3:2 and portrait 2:3. |
label | string | 'Aspect ratio' | Names the group for screen readers. Defaults to "Aspect ratio". |
action | AspectPickerAction | — | A button after the ratios, e.g. more ratios. |
disabled | boolean | false | |
haptics | boolean | true | A tick when the ratio changes. Defaults to true. |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- The highlight and the picked frame move with UI-thread springs on change; at rest nothing animates.
- 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 · 147 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- The highlight jumps and the frame changes without growing.
- Screen readers
- A radio group named "Aspect ratio"; each frame is a radio named by its label, e.g. "Portrait 2 by 3".
- Touch target
- Each ratio and the action button are 44 pt square.
Source files
components/kinetik/aspect-picker.tsx
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react';
import {
Pressable,
StyleSheet,
View,
type LayoutChangeEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, { 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 { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';
export type AspectOption = {
value: string;
/** Width divided by height, e.g. 1, 3 / 2 or 9 / 16. */
ratio: number;
/** Read by screen readers, e.g. "Square" or "Portrait 2 by 3". */
label: string;
};
export type AspectPickerAction = {
icon: ReactNode;
/** Accessible name, e.g. "More ratios". */
label: string;
onPress: () => void;
};
export type AspectPickerProps = {
value: string;
onChange: (value: string) => void;
/** Defaults to square, landscape 3:2 and portrait 2:3. */
options?: readonly AspectOption[];
/** Names the group for screen readers. Defaults to "Aspect ratio". */
label?: string;
/** A button after the ratios, e.g. more ratios. */
action?: AspectPickerAction;
disabled?: boolean;
/** A tick when the ratio changes. Defaults to true. */
haptics?: boolean;
style?: StyleProp<ViewStyle>;
};
const DEFAULT_OPTIONS: AspectOption[] = [
{ value: '1:1', ratio: 1, label: 'Square' },
{ value: '3:2', ratio: 3 / 2, label: 'Landscape 3 by 2' },
{ value: '2:3', ratio: 2 / 3, label: 'Portrait 2 by 3' },
];
/** The longest side of each ratio frame, in pt. */
const FRAME = 20;
/**
* A row of aspect-ratio frames drawn to scale. A soft square slides to the
* one you pick, and that frame brightens and grows a touch as it lands.
*/
export function AspectPicker({
value,
onChange,
options = DEFAULT_OPTIONS,
label = 'Aspect ratio',
action,
disabled = false,
haptics = true,
style,
}: AspectPickerProps) {
const { colors, radius, space } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const slots = useRef<Record<string, number>>({});
const [measured, setMeasured] = useState(0);
const x = useSharedValue(0);
const shown = useSharedValue(0);
const placed = useRef(false);
useEffect(() => {
const at = slots.current[value];
if (at === undefined) return;
shown.set(1);
if (!placed.current || reduced) {
x.set(at);
placed.current = true;
return;
}
x.set(withSpring(at, spring('smooth')));
}, [value, measured, reduced, x, shown]);
const onSlotLayout = (key: string) => (e: LayoutChangeEvent) => {
slots.current[key] = e.nativeEvent.layout.x;
setMeasured((m) => m + 1);
};
const highlight = useAnimatedStyle(() => ({
opacity: shown.get(),
transform: [{ translateX: x.get() }],
}));
return (
<View
style={[
styles.panel,
{
backgroundColor: colors.surfaceRaised,
borderRadius: radius.lg,
paddingHorizontal: space[3],
opacity: disabled ? 0.45 : 1,
},
style,
]}
>
<View accessibilityRole="radiogroup" accessibilityLabel={label} style={styles.row}>
<Animated.View
pointerEvents="none"
style={[
styles.highlight,
{ backgroundColor: withAlpha(colors.text, 0.12), borderRadius: radius.sm },
highlight,
]}
/>
{options.map((o, i) => (
<Fragment key={o.value}>
{i > 0 ? <View style={[styles.rule, { backgroundColor: colors.border }]} /> : null}
<Choice
option={o}
on={o.value === value}
disabled={disabled}
onLayout={onSlotLayout(o.value)}
onPress={() => {
haptic('tick');
onChange(o.value);
}}
/>
</Fragment>
))}
{action ? (
<>
<View style={[styles.rule, { backgroundColor: colors.border }]} />
<Pressable
accessibilityRole="button"
accessibilityLabel={action.label}
disabled={disabled}
onPress={action.onPress}
style={({ pressed }) => [styles.slot, { opacity: pressed ? 0.6 : 1 }]}
>
{action.icon}
</Pressable>
</>
) : null}
</View>
</View>
);
}
function Choice({
option,
on,
disabled,
onLayout,
onPress,
}: {
option: AspectOption;
on: boolean;
disabled: boolean;
onLayout: (e: LayoutChangeEvent) => void;
onPress: () => void;
}) {
const { colors } = useKinetikTheme();
const reduced = useReduceMotion();
const lift = useSharedValue(on ? 1 : 0);
useEffect(() => {
lift.set(reduced ? (on ? 1 : 0) : withSpring(on ? 1 : 0, spring('bouncy')));
}, [on, reduced, lift]);
const frame = useAnimatedStyle(() => ({ transform: [{ scale: 1 + lift.get() * 0.12 }] }));
const wide = option.ratio >= 1;
const w = wide ? FRAME : FRAME * option.ratio;
const h = wide ? FRAME / option.ratio : FRAME;
return (
<Pressable
accessibilityRole="radio"
accessibilityLabel={option.label}
accessibilityState={{ checked: on, disabled }}
disabled={disabled}
onLayout={onLayout}
onPress={on ? undefined : onPress}
style={styles.slot}
>
<Animated.View
style={[
styles.frame,
{ width: w, height: h, borderColor: on ? colors.text : colors.textMuted },
frame,
]}
/>
</Pressable>
);
}
const styles = StyleSheet.create({
panel: { minHeight: 56, justifyContent: 'center' },
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
highlight: { position: 'absolute', left: 0, width: minTouchTarget, height: minTouchTarget },
slot: {
width: minTouchTarget,
height: minTouchTarget,
alignItems: 'center',
justifyContent: 'center',
},
frame: { borderWidth: 1.75, borderRadius: 3 },
rule: { width: StyleSheet.hairlineWidth, height: 16 },
});