Press Jelly
A primary button that squashes like jelly under the finger and wobbles back on release.
import type { ReactNode } from 'react';
import {
ActivityIndicator,
StyleSheet,
Text,
View,
type AccessibilityActionEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
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 { gradient } from '../../lib/kinetik/tokens/gradient';
import { glow, minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';
export type PressJellyVariant = 'ember' | 'ice' | 'surface';
export type PressJellySize = 'md' | 'lg';
export type PressJellyProps = {
/** Button label. A string gets the variant's text style; any node is rendered as is. */
children: ReactNode;
onPress?: () => void;
/** Defaults to `ember`. */
variant?: PressJellyVariant;
/** `md` is 48 pt tall, `lg` 56 pt. Defaults to `md`. */
size?: PressJellySize;
/** Optional element before the label, e.g. an icon. */
leading?: ReactNode;
disabled?: boolean;
/** Shows a spinner and ignores presses. */
loading?: boolean;
/** How far the button squashes, 0–1. Defaults to 0.6. */
squish?: number;
/** Plays a light tap when the press lands. Defaults to true. */
haptics?: boolean;
/** Required when `children` is not a string. */
accessibilityLabel?: string;
accessibilityHint?: string;
/** Stretch to the parent's width. */
block?: boolean;
style?: StyleProp<ViewStyle>;
};
const HEIGHT: Record<PressJellySize, number> = { md: 48, lg: 56 };
/**
* A primary button that squashes like jelly under the finger and wobbles back
* on release. The squash starts on the UI thread the instant the finger lands.
*/
export function PressJelly({
children,
onPress,
variant = 'ember',
size = 'md',
leading,
disabled = false,
loading = false,
squish = 0.6,
haptics = true,
accessibilityLabel,
accessibilityHint,
block = false,
style,
}: PressJellyProps) {
const theme = useKinetikTheme();
const { colors, radius, font, space } = theme;
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const inactive = disabled || loading;
const k = Math.max(0, Math.min(1, squish));
const sx = useSharedValue(1);
const sy = useSharedValue(1);
const palette = {
ember: {
bg: colors.ember,
fg: colors.onEmber,
border: 'transparent',
shadow: glow(colors.ember),
},
ice: { bg: colors.ice, fg: colors.onIce, border: 'transparent', shadow: glow(colors.ice) },
surface: {
bg: colors.surfaceRaised,
fg: colors.text,
border: colors.borderStrong,
shadow: theme.shadow.sm,
},
}[variant];
const press = () => {
haptic('tap');
onPress?.();
};
const tap = Gesture.Tap()
.withTestId('press-jelly')
.enabled(!inactive)
.maxDuration(60_000)
.maxDistance(32)
.onBegin(() => {
if (reduced) {
sx.set(withSpring(0.98, spring('snappy', true)));
sy.set(withSpring(0.98, spring('snappy', true)));
return;
}
sx.set(withSpring(1 + 0.08 * k, spring('snappy')));
sy.set(withSpring(1 - 0.16 * k, spring('snappy')));
})
.onFinalize(() => {
// Release with an opposing kick so the shape overshoots and wobbles.
sx.set(withSpring(1, spring('bouncy', reduced, reduced ? 0 : -2.2 * k)));
sy.set(withSpring(1, spring('bouncy', reduced, reduced ? 0 : 3.4 * k)));
})
.onEnd((_e, success) => {
if (success) scheduleOnRN(press);
});
const jelly = useAnimatedStyle(() => ({
transform: [{ scaleX: sx.get() }, { scaleY: sy.get() }],
}));
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (e.nativeEvent.actionName === 'activate' && !inactive) press();
};
const label =
typeof children === 'string' ? (
<Text
numberOfLines={1}
style={{
color: palette.fg,
fontSize: size === 'lg' ? font.size.lg : font.size.md,
fontWeight: font.weight.bold,
letterSpacing: -0.2,
}}
>
{children}
</Text>
) : (
children
);
return (
<GestureDetector gesture={tap}>
<Animated.View
collapsable={false}
accessible
accessibilityRole="button"
accessibilityLabel={
accessibilityLabel ?? (typeof children === 'string' ? children : undefined)
}
accessibilityHint={accessibilityHint}
accessibilityState={{ disabled, busy: loading }}
accessibilityActions={[{ name: 'activate' }]}
onAccessibilityAction={onAccessibilityAction}
style={[
styles.button,
{
height: HEIGHT[size],
minWidth: Math.max(minTouchTarget, HEIGHT[size] * 2),
paddingHorizontal: size === 'lg' ? space[8] : space[6],
borderRadius: radius.pill,
backgroundColor: palette.bg,
borderColor: palette.border,
boxShadow: inactive ? undefined : palette.shadow,
opacity: disabled ? 0.45 : 1,
alignSelf: block ? 'stretch' : 'flex-start',
},
style,
jelly,
]}
>
{/* Top gloss gives the fill some volume. */}
<View
pointerEvents="none"
style={[
StyleSheet.absoluteFill,
{ borderRadius: radius.pill },
gradient(
`linear-gradient(180deg, ${withAlpha(colors.highlight, variant === 'surface' ? 0.06 : 0.22)} 0%, ${withAlpha(colors.highlight, 0)} 55%)`,
),
]}
/>
<View style={[styles.content, { gap: space[2], opacity: loading ? 0 : 1 }]}>
{leading}
{label}
</View>
{loading && (
<View style={[StyleSheet.absoluteFill, styles.center]}>
<ActivityIndicator color={palette.fg} />
</View>
)}
</Animated.View>
</GestureDetector>
);
}
const styles = StyleSheet.create({
button: {
justifyContent: 'center',
alignItems: 'center',
borderWidth: StyleSheet.hairlineWidth,
},
content: { flexDirection: 'row', alignItems: 'center' },
center: { alignItems: 'center', justifyContent: 'center' },
});Installation
npx kinetik-ui add press-jellypnpm dlx kinetik-ui add press-jellybunx kinetik-ui add press-jellyyarn dlx kinetik-ui add press-jellyThe 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/press-jelly.json
Install manually
- Install dependencies
npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/press-jelly.tsx
Usage
import { PressJelly } from '@/components/kinetik/press-jelly';
<PressJelly onPress={checkout} size="lg" block>
Continue
</PressJelly>;Variants: ember (primary), ice (secondary accent) and surface (neutral). Pass loading while work is in flight; presses are ignored and a spinner replaces the label without changing the button's size.
When to use
- The one primary action on a screen: continue, buy, send.
- Moments where a tactile response makes the app feel physical.
When not to use
- Dense toolbars or lists of actions. A screen full of wobbling buttons stops feeling special.
- Destructive actions that need a pause. Use Hold to Confirm.
Notes
- The squash starts on the UI thread the moment the finger lands, so it stays responsive while JavaScript is busy.
- Moving the finger more than 32 pt away cancels the press.
- Pass
accessibilityLabelwhen the label is not a plain string. - The button sizes to its label;
blockstretches it to the parent. Position it withstyle, e.g.style={{ alignSelf: 'center' }}.
Props
PressJellyProps
| Prop | Type | Default | Description |
|---|---|---|---|
childrenrequired | ReactNode | — | Button label. A string gets the variant's text style; any node is rendered as is. |
onPress | () => void | — | |
variant | PressJellyVariant | 'ember' | Defaults to `ember`. |
size | PressJellySize | 'md' | `md` is 48 pt tall, `lg` 56 pt. Defaults to `md`. |
leading | ReactNode | — | Optional element before the label, e.g. an icon. |
disabled | boolean | false | |
loading | boolean | false | Shows a spinner and ignores presses. |
squish | number | 0.6 | How far the button squashes, 0–1. Defaults to 0.6. |
haptics | boolean | true | Plays a light tap when the press lands. Defaults to true. |
accessibilityLabel | string | — | Required when `children` is not a string. |
accessibilityHint | string | — | |
block | boolean | false | Stretch to the parent's width. |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- Two scale springs per press, started on the UI thread; nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.18× 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
- A 2% press-in instead of the squash and wobble.
- Screen readers
- Button role with disabled and busy states; 'activate' presses it.
- Touch target
- 48 pt (md) or 56 pt (lg) tall, at least 96 pt wide.
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/press-jelly.tsx
import type { ReactNode } from 'react';
import {
ActivityIndicator,
StyleSheet,
Text,
View,
type AccessibilityActionEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
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 { gradient } from '../../lib/kinetik/tokens/gradient';
import { glow, minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';
export type PressJellyVariant = 'ember' | 'ice' | 'surface';
export type PressJellySize = 'md' | 'lg';
export type PressJellyProps = {
/** Button label. A string gets the variant's text style; any node is rendered as is. */
children: ReactNode;
onPress?: () => void;
/** Defaults to `ember`. */
variant?: PressJellyVariant;
/** `md` is 48 pt tall, `lg` 56 pt. Defaults to `md`. */
size?: PressJellySize;
/** Optional element before the label, e.g. an icon. */
leading?: ReactNode;
disabled?: boolean;
/** Shows a spinner and ignores presses. */
loading?: boolean;
/** How far the button squashes, 0–1. Defaults to 0.6. */
squish?: number;
/** Plays a light tap when the press lands. Defaults to true. */
haptics?: boolean;
/** Required when `children` is not a string. */
accessibilityLabel?: string;
accessibilityHint?: string;
/** Stretch to the parent's width. */
block?: boolean;
style?: StyleProp<ViewStyle>;
};
const HEIGHT: Record<PressJellySize, number> = { md: 48, lg: 56 };
/**
* A primary button that squashes like jelly under the finger and wobbles back
* on release. The squash starts on the UI thread the instant the finger lands.
*/
export function PressJelly({
children,
onPress,
variant = 'ember',
size = 'md',
leading,
disabled = false,
loading = false,
squish = 0.6,
haptics = true,
accessibilityLabel,
accessibilityHint,
block = false,
style,
}: PressJellyProps) {
const theme = useKinetikTheme();
const { colors, radius, font, space } = theme;
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const inactive = disabled || loading;
const k = Math.max(0, Math.min(1, squish));
const sx = useSharedValue(1);
const sy = useSharedValue(1);
const palette = {
ember: {
bg: colors.ember,
fg: colors.onEmber,
border: 'transparent',
shadow: glow(colors.ember),
},
ice: { bg: colors.ice, fg: colors.onIce, border: 'transparent', shadow: glow(colors.ice) },
surface: {
bg: colors.surfaceRaised,
fg: colors.text,
border: colors.borderStrong,
shadow: theme.shadow.sm,
},
}[variant];
const press = () => {
haptic('tap');
onPress?.();
};
const tap = Gesture.Tap()
.withTestId('press-jelly')
.enabled(!inactive)
.maxDuration(60_000)
.maxDistance(32)
.onBegin(() => {
if (reduced) {
sx.set(withSpring(0.98, spring('snappy', true)));
sy.set(withSpring(0.98, spring('snappy', true)));
return;
}
sx.set(withSpring(1 + 0.08 * k, spring('snappy')));
sy.set(withSpring(1 - 0.16 * k, spring('snappy')));
})
.onFinalize(() => {
// Release with an opposing kick so the shape overshoots and wobbles.
sx.set(withSpring(1, spring('bouncy', reduced, reduced ? 0 : -2.2 * k)));
sy.set(withSpring(1, spring('bouncy', reduced, reduced ? 0 : 3.4 * k)));
})
.onEnd((_e, success) => {
if (success) scheduleOnRN(press);
});
const jelly = useAnimatedStyle(() => ({
transform: [{ scaleX: sx.get() }, { scaleY: sy.get() }],
}));
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (e.nativeEvent.actionName === 'activate' && !inactive) press();
};
const label =
typeof children === 'string' ? (
<Text
numberOfLines={1}
style={{
color: palette.fg,
fontSize: size === 'lg' ? font.size.lg : font.size.md,
fontWeight: font.weight.bold,
letterSpacing: -0.2,
}}
>
{children}
</Text>
) : (
children
);
return (
<GestureDetector gesture={tap}>
<Animated.View
collapsable={false}
accessible
accessibilityRole="button"
accessibilityLabel={
accessibilityLabel ?? (typeof children === 'string' ? children : undefined)
}
accessibilityHint={accessibilityHint}
accessibilityState={{ disabled, busy: loading }}
accessibilityActions={[{ name: 'activate' }]}
onAccessibilityAction={onAccessibilityAction}
style={[
styles.button,
{
height: HEIGHT[size],
minWidth: Math.max(minTouchTarget, HEIGHT[size] * 2),
paddingHorizontal: size === 'lg' ? space[8] : space[6],
borderRadius: radius.pill,
backgroundColor: palette.bg,
borderColor: palette.border,
boxShadow: inactive ? undefined : palette.shadow,
opacity: disabled ? 0.45 : 1,
alignSelf: block ? 'stretch' : 'flex-start',
},
style,
jelly,
]}
>
{/* Top gloss gives the fill some volume. */}
<View
pointerEvents="none"
style={[
StyleSheet.absoluteFill,
{ borderRadius: radius.pill },
gradient(
`linear-gradient(180deg, ${withAlpha(colors.highlight, variant === 'surface' ? 0.06 : 0.22)} 0%, ${withAlpha(colors.highlight, 0)} 55%)`,
),
]}
/>
<View style={[styles.content, { gap: space[2], opacity: loading ? 0 : 1 }]}>
{leading}
{label}
</View>
{loading && (
<View style={[StyleSheet.absoluteFill, styles.center]}>
<ActivityIndicator color={palette.fg} />
</View>
)}
</Animated.View>
</GestureDetector>
);
}
const styles = StyleSheet.create({
button: {
justifyContent: 'center',
alignItems: 'center',
borderWidth: StyleSheet.hairlineWidth,
},
content: { flexDirection: 'row', alignItems: 'center' },
center: { alignItems: 'center', justifyContent: 'center' },
});