# Press Jelly

> A primary button that squashes like jelly under the finger and wobbles back on release.

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

## Install

```bash
npx kinetik-ui add press-jelly
```

With the shadcn CLI:

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

## Usage

```tsx
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 `accessibilityLabel` when the label is not a plain string.
- The button sizes to its label; `block` stretches it to the parent. Position it with `style`, e.g. `style={{ alignSelf: 'center' }}`.

## PressJellyProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `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>` |  |  |

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

## Performance

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.

## Source

`components/kinetik/press-jelly.tsx`

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