Skip to main content

👆 Pressable: Handling Touch

The modern, flexible way to make anything touchable in React Native

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Understand why Pressable is the recommended touch handler
  • Use all press states: onPressIn, onPressOut, onPress, and onLongPress
  • Create dynamic visual feedback using the style function
  • Improve touch targets with hitSlop and pressRetentionOffset
  • Migrate from legacy touchable components to Pressable
  • Build accessible, responsive touch interactions

⏱️ Estimated Time: 25-35 minutes

📑 In This Lesson

Introduction to Pressable

In mobile apps, touch is the primary way users interact with your interface. Unlike web browsers where we have onClick, mobile touch interactions are richer and more nuanced. React Native's Pressable component is the modern, recommended way to handle all touch interactions.

📖 What is Pressable?

Pressable is a core React Native component that detects touch interactions on its children. It provides detailed information about the press lifecycle and allows you to create rich, responsive touch feedback.

Web vs Native Touch

Coming from web development, you might be used to simple click handlers. Mobile touch is different:

🌐 Web Click

  • Single event: onClick
  • Binary state: clicked or not
  • Hover state available
  • Mouse precision

📱 Mobile Touch

  • Multiple events: press in, out, press, long press
  • Rich state: pressing, pressed, released
  • No hover (touch required)
  • Finger imprecision (need larger targets)

Basic Pressable Usage

import { Pressable, Text, StyleSheet } from 'react-native';

function MyButton() {
  return (
    <Pressable 
      onPress={() => console.log('Button pressed!')}
      style={styles.button}
    >
      <Text style={styles.buttonText}>Press Me</Text>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  button: {
    backgroundColor: '#2196F3',
    paddingVertical: 12,
    paddingHorizontal: 24,
    borderRadius: 8,
    alignItems: 'center',
  },
  buttonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: 'bold',
  },
});

✅ Pressable is the Future

React Native introduced Pressable in version 0.63 as a replacement for TouchableOpacity, TouchableHighlight, TouchableWithoutFeedback, and TouchableNativeFeedback. While those still work, Pressable is more flexible and is the recommended approach.

What Can Be Pressable?

Anything! Unlike buttons, Pressable wraps any content you want to make touchable:

// A pressable card
<Pressable onPress={() => navigateToDetails(item.id)}>
  <View style={styles.card}>
    <Image source={{ uri: item.image }} style={styles.cardImage} />
    <Text style={styles.cardTitle}>{item.title}</Text>
  </View>
</Pressable>

// A pressable icon
<Pressable onPress={() => toggleFavorite()}>
  <Text style={styles.icon}>{isFavorite ? '❤️' : '🤍'}</Text>
</Pressable>

// A pressable list item
<Pressable onPress={() => selectItem(item)}>
  <View style={styles.listItem}>
    <Text>{item.name}</Text>
    <Text style={styles.arrow}>→</Text>
  </View>
</Pressable>

Understanding Press States

One of Pressable's key features is exposing the full touch lifecycle through multiple callbacks. Understanding these states is crucial for creating polished, responsive interactions.

Touch Lifecycle 👇 onPressIn Finger touches ⏱️ onLongPress 500ms+ hold 👆 onPressOut Finger lifts onPress After release optional

The four press callbacks in order of the touch lifecycle

The Four Press Callbacks

Callback When Fired Common Use
onPressIn Finger touches the element Start visual feedback, animations
onPressOut Finger lifts or leaves element End visual feedback, animations
onPress After onPressOut (complete press) Primary action (navigate, submit)
onLongPress After holding ~500ms Secondary action (context menu)

Using All Press States

import { Pressable, Text, View, StyleSheet } from 'react-native';
import { useState } from 'react';

function PressStateDemo() {
  const [log, setLog] = useState<string[]>([]);
  
  const addLog = (event: string) => {
    setLog(prev => [...prev.slice(-4), `${new Date().toLocaleTimeString()}: ${event}`]);
  };

  return (
    <View style={styles.container}>
      <Pressable
        onPressIn={() => addLog('👇 onPressIn')}
        onPressOut={() => addLog('👆 onPressOut')}
        onPress={() => addLog('✓ onPress')}
        onLongPress={() => addLog('⏱️ onLongPress')}
        style={styles.button}
      >
        <Text style={styles.buttonText}>Press & Hold Me</Text>
      </Pressable>
      
      <View style={styles.logContainer}>
        {log.map((entry, index) => (
          <Text key={index} style={styles.logEntry}>{entry}</Text>
        ))}
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    padding: 20,
  },
  button: {
    backgroundColor: '#667eea',
    padding: 20,
    borderRadius: 12,
    alignItems: 'center',
  },
  buttonText: {
    color: 'white',
    fontSize: 18,
    fontWeight: 'bold',
  },
  logContainer: {
    marginTop: 20,
    padding: 16,
    backgroundColor: '#f5f5f5',
    borderRadius: 8,
    minHeight: 150,
  },
  logEntry: {
    fontFamily: 'monospace',
    fontSize: 12,
    marginVertical: 2,
  },
});

Event Sequence Examples

Quick Tap

  1. onPressIn — finger down
  2. onPressOut — finger up
  3. onPress — action triggered

Long Press

  1. onPressIn — finger down
  2. onLongPress — after 500ms
  3. onPressOut — finger up
  4. onPress — (if not cancelled)

Press & Slide Away

  1. onPressIn — finger down
  2. onPressOut — finger left area
  3. No onPress! — cancelled

Customizing Long Press Duration

// Default is 500ms
<Pressable
  onLongPress={() => showContextMenu()}
  delayLongPress={300}  // Trigger after 300ms instead
>

// Different delays for different interactions
<Pressable
  onLongPress={() => enterEditMode()}
  delayLongPress={1000}  // Require full second hold
>

Preventing onPress After Long Press

Sometimes you want long press to be the only action, without triggering the regular press:

import { useState, useRef } from 'react';

function LongPressOnly() {
  const didLongPress = useRef(false);

  const handleLongPress = () => {
    didLongPress.current = true;
    showContextMenu();
  };

  const handlePress = () => {
    if (didLongPress.current) {
      didLongPress.current = false;
      return;  // Don't trigger regular press
    }
    navigateToItem();
  };

  const handlePressIn = () => {
    didLongPress.current = false;
  };

  return (
    <Pressable
      onPressIn={handlePressIn}
      onPress={handlePress}
      onLongPress={handleLongPress}
    >
      <Text>Tap to open, hold for options</Text>
    </Pressable>
  );
}

⚠️ Order Matters

onPress fires after onPressOut, even for long presses. If you need to prevent onPress after a long press, you need to track it manually as shown above.

Press State Machine

stateDiagram-v2
    [*] --> Idle
    Idle --> Pressed: onPressIn
(finger down) Pressed --> Idle: onPressOut + onPress
(quick tap) Pressed --> LongPressed: 500ms elapsed
(onLongPress) LongPressed --> Idle: onPressOut
(finger up) Pressed --> Cancelled: finger slides away
(onPressOut only) Cancelled --> Idle note right of Pressed: Visual feedback active note right of LongPressed: Long press action triggered note right of Cancelled: No onPress fires

State transitions during touch interaction

Visual Feedback with Style Function

Users need immediate feedback when they touch something. Without it, the app feels broken or slow. Pressable's killer feature is the ability to pass a function to the style prop that receives the current press state.

💡 Key Insight

Unlike static styles, Pressable accepts a style function that receives { pressed } and returns styles. This lets you create dynamic visual feedback without managing state yourself.

The Style Function Pattern

import { Pressable, Text, StyleSheet } from 'react-native';

function DynamicButton() {
  return (
    <Pressable
      onPress={() => console.log('Pressed!')}
      style={({ pressed }) => [
        styles.button,
        pressed && styles.buttonPressed
      ]}
    >
      {({ pressed }) => (
        <Text style={[
          styles.buttonText,
          pressed && styles.buttonTextPressed
        ]}>
          {pressed ? 'Pressing...' : 'Press Me'}
        </Text>
      )}
    </Pressable>
  );
}

const styles = StyleSheet.create({
  button: {
    backgroundColor: '#2196F3',
    paddingVertical: 14,
    paddingHorizontal: 28,
    borderRadius: 10,
    alignItems: 'center',
  },
  buttonPressed: {
    backgroundColor: '#1976D2',
    transform: [{ scale: 0.98 }],
  },
  buttonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: 'bold',
  },
  buttonTextPressed: {
    opacity: 0.8,
  },
});

Understanding the Pressed State

// Style prop can be:
// 1. A static style object
<Pressable style={styles.button}>

// 2. An array of styles
<Pressable style={[styles.button, styles.primaryButton]}>

// 3. A function that returns styles
<Pressable style={({ pressed }) => ({
  backgroundColor: pressed ? '#1976D2' : '#2196F3',
  opacity: pressed ? 0.8 : 1,
})}>

// 4. A function that returns an array
<Pressable style={({ pressed }) => [
  styles.button,
  pressed && styles.pressed,
  disabled && styles.disabled,
]}>
Common Visual Feedback Patterns Opacity Normal Pressed Color Normal Pressed Scale Normal Pressed Shadow Normal Pressed

Different visual feedback techniques: opacity, color, scale, and shadow changes

Practical Feedback Examples

// Opacity fade (most common)
<Pressable
  style={({ pressed }) => ({
    opacity: pressed ? 0.7 : 1,
  })}
>

// Scale down effect
<Pressable
  style={({ pressed }) => ({
    transform: [{ scale: pressed ? 0.96 : 1 }],
  })}
>

// Background color change
<Pressable
  style={({ pressed }) => ({
    backgroundColor: pressed ? '#1565C0' : '#2196F3',
  })}
>

// Combination (professional feel)
<Pressable
  style={({ pressed }) => [
    styles.button,
    {
      backgroundColor: pressed ? '#1565C0' : '#2196F3',
      transform: [{ scale: pressed ? 0.98 : 1 }],
      shadowOpacity: pressed ? 0.1 : 0.3,
    }
  ]}
>

Children as a Function

Just like the style prop, children can be a function too:

import { Pressable, Text, View, StyleSheet } from 'react-native';

function InteractiveCard() {
  return (
    <Pressable
      onPress={() => console.log('Card pressed')}
      style={({ pressed }) => [
        styles.card,
        pressed && styles.cardPressed
      ]}
    >
      {({ pressed }) => (
        <View>
          <Text style={styles.cardTitle}>
            {pressed ? '✓ Selected' : 'Tap to Select'}
          </Text>
          <Text style={[
            styles.cardSubtitle,
            pressed && { color: '#1565C0' }
          ]}>
            {pressed ? 'Release to confirm' : 'Press and hold for options'}
          </Text>
        </View>
      )}
    </Pressable>
  );
}

const styles = StyleSheet.create({
  card: {
    backgroundColor: 'white',
    padding: 20,
    borderRadius: 12,
    borderWidth: 2,
    borderColor: '#e0e0e0',
  },
  cardPressed: {
    borderColor: '#2196F3',
    backgroundColor: '#e3f2fd',
  },
  cardTitle: {
    fontSize: 18,
    fontWeight: 'bold',
    marginBottom: 4,
  },
  cardSubtitle: {
    fontSize: 14,
    color: '#666',
  },
});

Creating Reusable Button Components

import { Pressable, Text, StyleSheet, ViewStyle, TextStyle } from 'react-native';

interface ButtonProps {
  title: string;
  onPress: () => void;
  variant?: 'primary' | 'secondary' | 'danger';
  disabled?: boolean;
}

function Button({ title, onPress, variant = 'primary', disabled = false }: ButtonProps) {
  const getBackgroundColor = (pressed: boolean) => {
    if (disabled) return '#ccc';
    
    const colors = {
      primary: { normal: '#2196F3', pressed: '#1976D2' },
      secondary: { normal: '#757575', pressed: '#616161' },
      danger: { normal: '#f44336', pressed: '#d32f2f' },
    };
    
    return pressed ? colors[variant].pressed : colors[variant].normal;
  };

  return (
    <Pressable
      onPress={onPress}
      disabled={disabled}
      style={({ pressed }) => [
        styles.button,
        {
          backgroundColor: getBackgroundColor(pressed),
          opacity: disabled ? 0.6 : 1,
          transform: [{ scale: pressed && !disabled ? 0.98 : 1 }],
        }
      ]}
    >
      <Text style={styles.buttonText}>{title}</Text>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  button: {
    paddingVertical: 14,
    paddingHorizontal: 24,
    borderRadius: 8,
    alignItems: 'center',
    justifyContent: 'center',
  },
  buttonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: '600',
  },
});

// Usage:
// <Button title="Save" onPress={handleSave} variant="primary" />
// <Button title="Cancel" onPress={handleCancel} variant="secondary" />
// <Button title="Delete" onPress={handleDelete} variant="danger" />

✅ Pro Tip: Consistent Feedback

Create a reusable button component (like above) that handles all your press feedback consistently. This ensures every button in your app feels the same, which users find reassuring.

HitSlop and Touch Targets

Fingers are imprecise. Apple's Human Interface Guidelines recommend touch targets of at least 44×44 points. But sometimes you can't make the visual element that large. Enter hitSlop.

📖 What is HitSlop?

hitSlop extends the touchable area beyond the visible bounds of a component. The user can touch outside the visible element and still trigger the press. This is invisible to users but makes small elements much easier to tap.

HitSlop Extends Touch Area Without hitSlop × Touch area = visual 50×50px (too small!) miss With hitSlop={20} × Touch area = visual + 20px 90×90px (easy to tap!) hit! Visible element Touch area

hitSlop makes small elements easier to tap without changing their visual size

Using hitSlop

import { Pressable, Text, StyleSheet } from 'react-native';

// Small close button with extended touch area
function CloseButton({ onClose }: { onClose: () => void }) {
  return (
    <Pressable
      onPress={onClose}
      hitSlop={20}  // 20px on all sides
      style={styles.closeButton}
    >
      <Text style={styles.closeIcon}>×</Text>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  closeButton: {
    width: 24,
    height: 24,
    borderRadius: 12,
    backgroundColor: '#f0f0f0',
    alignItems: 'center',
    justifyContent: 'center',
  },
  closeIcon: {
    fontSize: 18,
    color: '#666',
    fontWeight: 'bold',
  },
});

hitSlop Options

// Shorthand: same value all sides
<Pressable hitSlop={20}>

// Object: different values per side
<Pressable hitSlop={{
  top: 10,
  right: 20,
  bottom: 10,
  left: 20,
}}>

// Mixed: extend only specific sides
<Pressable hitSlop={{
  top: 15,
  bottom: 15,
  // left and right default to 0
}}>

pressRetentionOffset

Another related prop is pressRetentionOffset. This determines how far the finger can move outside the element before the press is cancelled:

// User can slide finger 30px outside before cancellation
<Pressable
  onPress={handlePress}
  pressRetentionOffset={{ top: 30, left: 30, right: 30, bottom: 30 }}
>
  <Text>Forgiving Button</Text>
</Pressable>

// Useful for buttons where users might "jitter" their touch
// Default is {top: 20, left: 20, right: 20, bottom: 30}
flowchart LR
    subgraph hitSlop["hitSlop"]
        A[Extends where touch
can START] end subgraph pressRetention["pressRetentionOffset"] B[Extends where touch
can MOVE TO
without cancelling] end A --> |"Press starts outside
visible bounds"| C[Touch registers] B --> |"Finger slides outside
visible bounds"| D[Press still active] style hitSlop fill:#e3f2fd,stroke:#1976d2 style pressRetention fill:#fff3e0,stroke:#ff9800

hitSlop affects where presses can start; pressRetentionOffset affects where they can move

When to Use hitSlop

✅ Good Use Cases

  • Close/dismiss buttons (×)
  • Icon-only buttons
  • Navigation arrows
  • Small action icons
  • Checkbox/radio buttons

⚠️ Watch Out

  • Don't overlap adjacent touch areas
  • Test on actual devices
  • Consider left-handed users
  • Larger visual targets are still better

Practical Example: Icon Buttons

import { Pressable, View, Text, StyleSheet } from 'react-native';

function IconButton({ 
  icon, 
  label, 
  onPress 
}: { 
  icon: string; 
  label: string; 
  onPress: () => void;
}) {
  return (
    <Pressable
      onPress={onPress}
      hitSlop={15}
      style={({ pressed }) => [
        styles.iconButton,
        pressed && styles.iconButtonPressed
      ]}
      accessibilityLabel={label}
      accessibilityRole="button"
    >
      <Text style={styles.icon}>{icon}</Text>
    </Pressable>
  );
}

function ActionBar() {
  return (
    <View style={styles.actionBar}>
      <IconButton icon="❤️" label="Like" onPress={() => {}} />
      <IconButton icon="💬" label="Comment" onPress={() => {}} />
      <IconButton icon="↗️" label="Share" onPress={() => {}} />
      <IconButton icon="🔖" label="Save" onPress={() => {}} />
    </View>
  );
}

const styles = StyleSheet.create({
  actionBar: {
    flexDirection: 'row',
    justifyContent: 'space-around',
    paddingVertical: 12,
  },
  iconButton: {
    padding: 8,
  },
  iconButtonPressed: {
    opacity: 0.6,
    transform: [{ scale: 0.95 }],
  },
  icon: {
    fontSize: 24,
  },
});

Pressable vs Legacy Touchables

Before Pressable, React Native had several touchable components. You'll still see these in older code and tutorials. Understanding them helps you migrate to Pressable.

The Legacy Family

Component Feedback Platform
TouchableOpacity Fades opacity on press Both
TouchableHighlight Darkens background (underlay) Both
TouchableWithoutFeedback No visual feedback Both
TouchableNativeFeedback Android ripple effect Android only

Why Pressable is Better

🔄 Unified API

One component instead of four. No need to choose between opacity, highlight, or native feedback.

🎨 Full Control

Style function gives you complete control over visual feedback. Create any effect you want.

📱 Cross-Platform

Works identically on iOS and Android. Use android_ripple for native Android feedback when desired.

⚡ Modern API

Better TypeScript support, cleaner props, and actively maintained by React Native team.

Migration Examples

// ❌ Old: TouchableOpacity
import { TouchableOpacity, Text } from 'react-native';

<TouchableOpacity 
  onPress={handlePress}
  activeOpacity={0.7}
>
  <Text>Press Me</Text>
</TouchableOpacity>

// ✅ New: Pressable with opacity
import { Pressable, Text } from 'react-native';

<Pressable 
  onPress={handlePress}
  style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
  <Text>Press Me</Text>
</Pressable>
// ❌ Old: TouchableHighlight
import { TouchableHighlight, Text, View } from 'react-native';

<TouchableHighlight
  onPress={handlePress}
  underlayColor="#ddd"
>
  <View style={styles.button}>
    <Text>Press Me</Text>
  </View>
</TouchableHighlight>

// ✅ New: Pressable with background change
import { Pressable, Text } from 'react-native';

<Pressable
  onPress={handlePress}
  style={({ pressed }) => [
    styles.button,
    { backgroundColor: pressed ? '#ddd' : '#fff' }
  ]}
>
  <Text>Press Me</Text>
</Pressable>

Android Ripple Effect

For the native Android ripple effect, Pressable provides android_ripple:

import { Pressable, Text, StyleSheet, Platform } from 'react-native';

function RippleButton() {
  return (
    <Pressable
      onPress={() => console.log('Pressed')}
      android_ripple={{
        color: 'rgba(0, 0, 0, 0.2)',  // Ripple color
        borderless: false,            // Contained within bounds
        radius: undefined,            // Auto-calculate from layout
      }}
      style={({ pressed }) => [
        styles.button,
        // Fallback for iOS (no ripple)
        Platform.OS === 'ios' && pressed && styles.iosPressed
      ]}
    >
      <Text style={styles.buttonText}>Native Ripple on Android</Text>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  button: {
    backgroundColor: '#2196F3',
    paddingVertical: 14,
    paddingHorizontal: 24,
    borderRadius: 8,
    alignItems: 'center',
  },
  iosPressed: {
    opacity: 0.8,
  },
  buttonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: 'bold',
  },
});

💡 android_ripple Options

  • color: Ripple color (use rgba for transparency)
  • borderless: If true, ripple extends beyond component bounds
  • radius: Custom ripple radius (default: auto from layout)
  • foreground: If true, ripple renders above children (API 23+)

Cross-Platform Button Component

import { Pressable, Text, StyleSheet, Platform } from 'react-native';

interface CrossPlatformButtonProps {
  title: string;
  onPress: () => void;
}

function CrossPlatformButton({ title, onPress }: CrossPlatformButtonProps) {
  return (
    <Pressable
      onPress={onPress}
      android_ripple={{
        color: 'rgba(255, 255, 255, 0.3)',
      }}
      style={({ pressed }) => [
        styles.button,
        // Only apply press style on iOS (Android has ripple)
        Platform.OS === 'ios' && pressed && styles.buttonPressed,
      ]}
    >
      <Text style={styles.buttonText}>{title}</Text>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  button: {
    backgroundColor: '#6200ee',
    paddingVertical: 14,
    paddingHorizontal: 24,
    borderRadius: 8,
    alignItems: 'center',
    // Android needs overflow hidden for ripple containment
    overflow: 'hidden',
  },
  buttonPressed: {
    backgroundColor: '#3700b3',
  },
  buttonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: '600',
  },
});
flowchart TD
    A[Using legacy touchable?] --> B{Which one?}
    
    B -->|TouchableOpacity| C[Use Pressable with
opacity style function] B -->|TouchableHighlight| D[Use Pressable with
backgroundColor change] B -->|TouchableNativeFeedback| E[Use Pressable with
android_ripple] B -->|TouchableWithoutFeedback| F[Use Pressable with
no visual feedback] C --> G[✅ Modern & flexible] D --> G E --> G F --> G style G fill:#e8f5e9,stroke:#4caf50

Migration path from legacy touchable components to Pressable

Hands-On Exercises

Time to put Pressable into practice! These exercises progress from basic to advanced.

Exercise 1: Basic Press Feedback

Goal: Create a button with visual press feedback.

Requirements:

  • Button that changes background color when pressed
  • Slight scale reduction on press (0.97)
  • Text says "Press Me" normally, "Pressing..." when pressed
💡 Hint

Use both the style function and children function patterns. Remember to use the pressed parameter from both.

✅ Solution
import { Pressable, Text, StyleSheet, View } from 'react-native';

export default function FeedbackButton() {
  return (
    <View style={styles.container}>
      <Pressable
        onPress={() => console.log('Button pressed!')}
        style={({ pressed }) => [
          styles.button,
          {
            backgroundColor: pressed ? '#1565C0' : '#2196F3',
            transform: [{ scale: pressed ? 0.97 : 1 }],
          }
        ]}
      >
        {({ pressed }) => (
          <Text style={styles.buttonText}>
            {pressed ? 'Pressing...' : 'Press Me'}
          </Text>
        )}
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    padding: 20,
  },
  button: {
    paddingVertical: 16,
    paddingHorizontal: 32,
    borderRadius: 12,
    minWidth: 160,
    alignItems: 'center',
  },
  buttonText: {
    color: 'white',
    fontSize: 18,
    fontWeight: 'bold',
  },
});

Exercise 2: Press State Logger

Goal: Create a component that logs all press states.

Requirements:

  • Pressable area with all four callbacks: onPressIn, onPressOut, onPress, onLongPress
  • Display the last 5 events in a log area below
  • Include timestamp with each event
  • Different emoji for each event type
💡 Hint

Use useState to manage the log array. Use slice(-5) to keep only the last 5 entries.

✅ Solution
import { useState } from 'react';
import { Pressable, Text, View, StyleSheet, ScrollView } from 'react-native';

type LogEntry = {
  id: number;
  time: string;
  event: string;
  emoji: string;
};

export default function PressLogger() {
  const [log, setLog] = useState<LogEntry[]>([]);
  const [nextId, setNextId] = useState(0);

  const addLog = (event: string, emoji: string) => {
    const time = new Date().toLocaleTimeString();
    setLog(prev => [...prev.slice(-4), { id: nextId, time, event, emoji }]);
    setNextId(prev => prev + 1);
  };

  return (
    <View style={styles.container}>
      <Pressable
        onPressIn={() => addLog('onPressIn', '👇')}
        onPressOut={() => addLog('onPressOut', '👆')}
        onPress={() => addLog('onPress', '✅')}
        onLongPress={() => addLog('onLongPress', '⏱️')}
        delayLongPress={500}
        style={({ pressed }) => [
          styles.pressArea,
          pressed && styles.pressAreaActive
        ]}
      >
        {({ pressed }) => (
          <Text style={styles.pressText}>
            {pressed ? '🔵 Pressing...' : '⚪ Press or Hold Me'}
          </Text>
        )}
      </Pressable>

      <View style={styles.logContainer}>
        <Text style={styles.logTitle}>Event Log:</Text>
        <ScrollView style={styles.logScroll}>
          {log.length === 0 ? (
            <Text style={styles.emptyLog}>No events yet...</Text>
          ) : (
            log.map(entry => (
              <Text key={entry.id} style={styles.logEntry}>
                {entry.emoji} {entry.time} — {entry.event}
              </Text>
            ))
          )}
        </ScrollView>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 20,
  },
  pressArea: {
    backgroundColor: '#e3f2fd',
    padding: 40,
    borderRadius: 16,
    alignItems: 'center',
    borderWidth: 3,
    borderColor: '#2196F3',
  },
  pressAreaActive: {
    backgroundColor: '#bbdefb',
    borderColor: '#1565C0',
  },
  pressText: {
    fontSize: 18,
    fontWeight: '600',
    color: '#1565C0',
  },
  logContainer: {
    marginTop: 24,
    flex: 1,
    backgroundColor: '#f5f5f5',
    borderRadius: 12,
    padding: 16,
  },
  logTitle: {
    fontSize: 16,
    fontWeight: 'bold',
    marginBottom: 12,
    color: '#333',
  },
  logScroll: {
    flex: 1,
  },
  logEntry: {
    fontFamily: 'monospace',
    fontSize: 14,
    paddingVertical: 6,
    borderBottomWidth: 1,
    borderBottomColor: '#e0e0e0',
  },
  emptyLog: {
    color: '#999',
    fontStyle: 'italic',
  },
});

Exercise 3: Icon Button with hitSlop

Goal: Create small icon buttons with proper touch targets.

Requirements:

  • Create an action bar with 4 small icon buttons (emoji icons work fine)
  • Visual size should be small (32×32)
  • Touch target should be at least 44×44 (use hitSlop)
  • Add press feedback (opacity or scale)
  • Add accessibility labels
💡 Hint

Calculate hitSlop: if visual is 32px and target should be 44px, you need (44-32)/2 = 6px on each side. Add a bit more for comfort.

✅ Solution
import { Pressable, View, Text, StyleSheet } from 'react-native';

interface ActionButtonProps {
  icon: string;
  label: string;
  onPress: () => void;
}

function ActionButton({ icon, label, onPress }: ActionButtonProps) {
  return (
    <Pressable
      onPress={onPress}
      hitSlop={10}  // Visual is 32px, this makes touch area ~52px
      accessibilityLabel={label}
      accessibilityRole="button"
      style={({ pressed }) => [
        styles.iconButton,
        pressed && styles.iconButtonPressed
      ]}
    >
      <Text style={styles.icon}>{icon}</Text>
    </Pressable>
  );
}

export default function ActionBar() {
  const handleAction = (action: string) => {
    console.log(`${action} pressed`);
  };

  return (
    <View style={styles.container}>
      <View style={styles.actionBar}>
        <ActionButton 
          icon="❤️" 
          label="Like" 
          onPress={() => handleAction('Like')} 
        />
        <ActionButton 
          icon="💬" 
          label="Comment" 
          onPress={() => handleAction('Comment')} 
        />
        <ActionButton 
          icon="↗️" 
          label="Share" 
          onPress={() => handleAction('Share')} 
        />
        <ActionButton 
          icon="🔖" 
          label="Save" 
          onPress={() => handleAction('Save')} 
        />
      </View>
      
      <Text style={styles.hint}>
        Buttons are 32×32px but touchable area is larger!
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    padding: 20,
  },
  actionBar: {
    flexDirection: 'row',
    justifyContent: 'space-around',
    backgroundColor: 'white',
    paddingVertical: 16,
    borderRadius: 12,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 8,
    elevation: 3,
  },
  iconButton: {
    width: 32,
    height: 32,
    alignItems: 'center',
    justifyContent: 'center',
  },
  iconButtonPressed: {
    opacity: 0.5,
    transform: [{ scale: 0.9 }],
  },
  icon: {
    fontSize: 24,
  },
  hint: {
    marginTop: 16,
    textAlign: 'center',
    color: '#666',
    fontSize: 12,
  },
});

Exercise 4: Selectable Card List

Goal: Create a list of selectable cards with tap and long-press actions.

Requirements:

  • List of cards showing item name and description
  • Tap to select/deselect (visual change when selected)
  • Long press to show an alert with "Delete?" confirmation
  • Selected cards have different background and border
💡 Hint

Use a Set or array to track selected IDs. Use the useRef trick to prevent onPress after onLongPress if needed.

✅ Solution
import { useState, useRef } from 'react';
import { Pressable, View, Text, StyleSheet, Alert, ScrollView } from 'react-native';

interface Item {
  id: string;
  name: string;
  description: string;
}

const ITEMS: Item[] = [
  { id: '1', name: 'Project Alpha', description: 'Mobile app development' },
  { id: '2', name: 'Project Beta', description: 'Backend API design' },
  { id: '3', name: 'Project Gamma', description: 'UI/UX improvements' },
  { id: '4', name: 'Project Delta', description: 'Performance optimization' },
];

export default function SelectableCardList() {
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
  const longPressedRef = useRef(false);

  const toggleSelection = (id: string) => {
    if (longPressedRef.current) {
      longPressedRef.current = false;
      return;
    }
    
    setSelectedIds(prev => {
      const next = new Set(prev);
      if (next.has(id)) {
        next.delete(id);
      } else {
        next.add(id);
      }
      return next;
    });
  };

  const handleLongPress = (item: Item) => {
    longPressedRef.current = true;
    Alert.alert(
      'Delete Item?',
      `Are you sure you want to delete "${item.name}"?`,
      [
        { text: 'Cancel', style: 'cancel' },
        { text: 'Delete', style: 'destructive', onPress: () => {
          console.log(`Deleting ${item.id}`);
        }},
      ]
    );
  };

  return (
    <ScrollView style={styles.container}>
      <Text style={styles.title}>Tap to select, hold to delete</Text>
      
      {ITEMS.map(item => {
        const isSelected = selectedIds.has(item.id);
        
        return (
          <Pressable
            key={item.id}
            onPressIn={() => { longPressedRef.current = false; }}
            onPress={() => toggleSelection(item.id)}
            onLongPress={() => handleLongPress(item)}
            delayLongPress={500}
            style={({ pressed }) => [
              styles.card,
              isSelected && styles.cardSelected,
              pressed && styles.cardPressed,
            ]}
          >
            <View style={styles.cardContent}>
              <View style={styles.cardHeader}>
                <Text style={[
                  styles.cardName,
                  isSelected && styles.cardNameSelected
                ]}>
                  {item.name}
                </Text>
                {isSelected && (
                  <Text style={styles.checkmark}>✓</Text>
                )}
              </View>
              <Text style={styles.cardDescription}>{item.description}</Text>
            </View>
          </Pressable>
        );
      })}
      
      <Text style={styles.selectedCount}>
        {selectedIds.size} item{selectedIds.size !== 1 ? 's' : ''} selected
      </Text>
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 16,
  },
  title: {
    fontSize: 14,
    color: '#666',
    marginBottom: 16,
    textAlign: 'center',
  },
  card: {
    backgroundColor: 'white',
    borderRadius: 12,
    marginBottom: 12,
    borderWidth: 2,
    borderColor: '#e0e0e0',
    overflow: 'hidden',
  },
  cardSelected: {
    borderColor: '#2196F3',
    backgroundColor: '#e3f2fd',
  },
  cardPressed: {
    opacity: 0.9,
    transform: [{ scale: 0.99 }],
  },
  cardContent: {
    padding: 16,
  },
  cardHeader: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    marginBottom: 4,
  },
  cardName: {
    fontSize: 18,
    fontWeight: '600',
    color: '#333',
  },
  cardNameSelected: {
    color: '#1565C0',
  },
  checkmark: {
    fontSize: 20,
    color: '#2196F3',
    fontWeight: 'bold',
  },
  cardDescription: {
    fontSize: 14,
    color: '#666',
  },
  selectedCount: {
    textAlign: 'center',
    marginTop: 8,
    color: '#666',
    fontSize: 14,
  },
});

Challenge: Custom Button Component Library

🏆 Bonus Challenge

Goal: Create a reusable Button component that can be used throughout an app.

Features:

  • Multiple variants: primary, secondary, outline, danger
  • Multiple sizes: small, medium, large
  • Loading state with ActivityIndicator
  • Disabled state
  • Optional left or right icon
  • Cross-platform feedback (ripple on Android, opacity on iOS)
✅ Solution
import { 
  Pressable, 
  Text, 
  StyleSheet, 
  ActivityIndicator, 
  View,
  Platform,
  ViewStyle,
  TextStyle,
} from 'react-native';

type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger';
type ButtonSize = 'small' | 'medium' | 'large';

interface ButtonProps {
  title: string;
  onPress: () => void;
  variant?: ButtonVariant;
  size?: ButtonSize;
  loading?: boolean;
  disabled?: boolean;
  leftIcon?: string;
  rightIcon?: string;
}

const VARIANT_COLORS = {
  primary: { bg: '#2196F3', bgPressed: '#1976D2', text: 'white', border: 'transparent' },
  secondary: { bg: '#757575', bgPressed: '#616161', text: 'white', border: 'transparent' },
  outline: { bg: 'transparent', bgPressed: '#f5f5f5', text: '#2196F3', border: '#2196F3' },
  danger: { bg: '#f44336', bgPressed: '#d32f2f', text: 'white', border: 'transparent' },
};

const SIZE_STYLES = {
  small: { paddingVertical: 8, paddingHorizontal: 16, fontSize: 14 },
  medium: { paddingVertical: 12, paddingHorizontal: 24, fontSize: 16 },
  large: { paddingVertical: 16, paddingHorizontal: 32, fontSize: 18 },
};

export default function Button({
  title,
  onPress,
  variant = 'primary',
  size = 'medium',
  loading = false,
  disabled = false,
  leftIcon,
  rightIcon,
}: ButtonProps) {
  const colors = VARIANT_COLORS[variant];
  const sizeStyle = SIZE_STYLES[size];
  const isDisabled = disabled || loading;

  return (
    <Pressable
      onPress={onPress}
      disabled={isDisabled}
      android_ripple={{
        color: 'rgba(255, 255, 255, 0.3)',
      }}
      style={({ pressed }) => [
        styles.button,
        {
          backgroundColor: isDisabled 
            ? '#ccc' 
            : pressed 
              ? colors.bgPressed 
              : colors.bg,
          borderColor: isDisabled ? '#ccc' : colors.border,
          paddingVertical: sizeStyle.paddingVertical,
          paddingHorizontal: sizeStyle.paddingHorizontal,
          opacity: isDisabled && Platform.OS === 'ios' ? 0.6 : 1,
          transform: [{ 
            scale: pressed && !isDisabled && Platform.OS === 'ios' ? 0.98 : 1 
          }],
        },
      ]}
    >
      <View style={styles.content}>
        {loading ? (
          <ActivityIndicator 
            color={colors.text} 
            size={size === 'small' ? 'small' : 'small'}
          />
        ) : (
          <>
            {leftIcon && (
              <Text style={[styles.icon, { fontSize: sizeStyle.fontSize }]}>
                {leftIcon}
              </Text>
            )}
            <Text style={[
              styles.text,
              { 
                color: isDisabled ? '#999' : colors.text,
                fontSize: sizeStyle.fontSize,
              }
            ]}>
              {title}
            </Text>
            {rightIcon && (
              <Text style={[styles.icon, { fontSize: sizeStyle.fontSize }]}>
                {rightIcon}
              </Text>
            )}
          </>
        )}
      </View>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  button: {
    borderRadius: 8,
    borderWidth: 2,
    alignItems: 'center',
    justifyContent: 'center',
    overflow: 'hidden',
  },
  content: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'center',
  },
  text: {
    fontWeight: '600',
  },
  icon: {
    marginHorizontal: 6,
  },
});

// Usage examples:
// <Button title="Save" onPress={handleSave} />
// <Button title="Cancel" onPress={handleCancel} variant="secondary" />
// <Button title="Delete" onPress={handleDelete} variant="danger" />
// <Button title="Sign Up" onPress={handleSignUp} variant="outline" size="large" />
// <Button title="Loading..." onPress={() => {}} loading />
// <Button title="Add Item" onPress={handleAdd} leftIcon="+" />
// <Button title="Next" onPress={handleNext} rightIcon="→" />

Summary

🎉 Key Takeaways

  • Pressable is the modern standard for handling touch in React Native
  • Four press states: onPressIn, onPressOut, onPress, onLongPress
  • Style function receives { pressed } for dynamic visual feedback
  • Children function also receives { pressed } for dynamic content
  • hitSlop extends touch area beyond visible bounds for small elements
  • pressRetentionOffset controls how far finger can move before cancellation
  • android_ripple provides native Android feedback
  • Replaces TouchableOpacity, TouchableHighlight, TouchableWithoutFeedback, TouchableNativeFeedback
  • Always add accessibility labels for screen readers

Quick Reference

// Basic Pressable
<Pressable onPress={handlePress}>
  <Text>Press Me</Text>
</Pressable>

// All press callbacks
<Pressable
  onPressIn={() => {}}   // Finger down
  onPressOut={() => {}}  // Finger up
  onPress={() => {}}     // Complete press
  onLongPress={() => {}} // Hold 500ms+
  delayLongPress={500}   // Customize long press delay
>

// Style function for visual feedback
<Pressable
  style={({ pressed }) => [
    styles.button,
    pressed && styles.pressed
  ]}
>

// Children function for dynamic content
<Pressable>
  {({ pressed }) => (
    <Text>{pressed ? 'Pressing...' : 'Press Me'}</Text>
  )}
</Pressable>

// Extended touch area
<Pressable hitSlop={20}>
<Pressable hitSlop={{ top: 10, right: 20, bottom: 10, left: 20 }}>

// Android ripple effect
<Pressable android_ripple={{ color: 'rgba(0,0,0,0.2)' }}>

// Accessibility
<Pressable
  accessibilityLabel="Close button"
  accessibilityRole="button"
  accessibilityHint="Closes the dialog"
>

Common Patterns Cheat Sheet

Opacity Feedback

style={({ pressed }) => ({
  opacity: pressed ? 0.7 : 1
})}

Scale Feedback

style={({ pressed }) => ({
  transform: [{ 
    scale: pressed ? 0.97 : 1 
  }]
})}

Color Change

style={({ pressed }) => ({
  backgroundColor: pressed 
    ? '#1565C0' 
    : '#2196F3'
})}

Combined

style={({ pressed }) => [
  styles.button,
  pressed && styles.pressed
]}

🚀 What's Next?

Now that you can make anything touchable, we'll learn about TextInput — capturing user input with text fields, handling keyboard types, and managing focus in React Native.

👆 Touch Mastered!

You now have complete control over touch interactions in your apps. From simple taps to long presses, from visual feedback to extended touch targets — your buttons will feel polished and professional!