Kinetik UI

Press Jelly

.md

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

v1.0.0Live on webreact-native-gesture-handlerreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add press-jelly

The 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

  1. Install dependencies
    npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/press-jelly.tsx
    View source files

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 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' }}.

Props

PressJellyProps

PropTypeDefaultDescription
childrenrequiredReactNode—Button label. A string gets the variant's text style; any node is rendered as is.
onPress() => void—
variantPressJellyVariant'ember'Defaults to `ember`.
sizePressJellySize'md'`md` is 48 pt tall, `lg` 56 pt. Defaults to `md`.
leadingReactNode—Optional element before the label, e.g. an icon.
disabledbooleanfalse
loadingbooleanfalseShows a spinner and ignores presses.
squishnumber0.6How far the button squashes, 0–1. Defaults to 0.6.
hapticsbooleantruePlays a light tap when the press lands. Defaults to true.
accessibilityLabelstring—Required when `children` is not a string.
accessibilityHintstring—
blockbooleanfalseStretch to the parent's width.
styleStyleProp<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' },
});