EduFlow

Section 1 · The Mental Model

Touch and Typing: Pressable and TextInput

19 min3 min readCode playground

Lesson at a glance

Make Trailhead respond to touch with Pressable and accept typing with a controlled TextInput, including pressed feedback, tap targets, mobile keyboards and parsing what users type.

  1. 1Pressable: the default for anything tappable
  2. 2Tap targets and hitSlop
  3. 3TextInput: controlled, and always a string
  4. 4Parsing what people type

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

A web button gets hover styles, focus rings and a pointer cursor for free. On a phone there’s no hover and no cursor: there’s a thumb, which is wide, imprecise and moving. Good touch handling on mobile comes down to three things: a large enough target, feedback the instant the finger lands, and inputs that open the right keyboard.

Pressable: the default for anything tappable

Pressable wraps any content and reports touches. Its style prop can be a function that receives { pressed }, so you can dim or tint the button while the finger is down:

src/components/PrimaryButton.tsx
import { Pressable, Text } from 'react-native';

type Props = { label: string; onPress: () => void; disabled?: boolean };

export function PrimaryButton({ label, onPress, disabled = false }: Props) {
  return (
    <Pressable
      onPress={onPress}
      disabled={disabled}
      accessibilityRole="button"
      accessibilityState={{ disabled }}
      style={({ pressed }) => ({
        backgroundColor: disabled ? '#9bb5a7' : '#2f6f4e',
        opacity: pressed ? 0.75 : 1,
        paddingVertical: 14,
        paddingHorizontal: 20,
        borderRadius: 12,
        alignItems: 'center',
      })}
    >
      <Text style={{ color: 'white', fontSize: 16, fontWeight: '600' }}>{label}</Text>
    </Pressable>
  );
}

accessibilityRole="button" makes VoiceOver and TalkBack announce it as a button; the text inside becomes its label. For icon-only buttons, add accessibilityLabel="Delete hike" because there’s no text to read.

You’ll also see Button and TouchableOpacity in older code. Button can’t be styled beyond a color, and the Touchable* family is the older API. Reach for Pressable and build your own button component once.

Tap targets and hitSlop

Apple’s guideline is a minimum of 44 by 44 points; Material Design says 48 by 48 dp. A 24-point icon is fine visually, but too small to hit while walking down a trail. hitSlop grows the touch area without changing layout:

TSX
<Pressable
  onPress={() => onDelete(hike.id)}
  hitSlop={10}
  accessibilityRole="button"
  accessibilityLabel={`Delete ${hike.name}`}
>
  <TrashIcon size={24} />
</Pressable>

Pressable also gives you onLongPress (fired after about half a second by default) and onPressIn / onPressOut for the exact moments the finger lands and lifts. Trailhead uses a long press on a hike card to open a quick-actions sheet, a pattern iOS and Android users already know from their home screens. Use it for shortcuts only: nobody discovers a long press on their own, so every action behind one must also be reachable some other way.

On Android you can add android_ripple={{ color: '#00000022' }} for the platform’s ripple effect; iOS ignores that prop, so the pressed style still matters there.

TextInput: controlled, and always a string

TextInput works like a controlled <input>, with one naming difference. Use onChangeText, which hands you the new string. onChange gives you a native event object instead.

src/components/QuickLog.tsx
import { useState } from 'react';
import { Text, TextInput, View } from 'react-native';
import { PrimaryButton } from './PrimaryButton';
import { parseDistanceKm } from '../lib/parseDistance';

export function QuickLog({ onSave }: { onSave: (km: number) => void }) {
  const [text, setText] = useState('');
  const km = parseDistanceKm(text);

  return (
    <View style={{ gap: 8 }}>
      <Text>Distance (km)</Text>
      <TextInput
        value={text}
        onChangeText={setText}
        accessibilityLabel="Distance (km)"
        keyboardType="decimal-pad"
        placeholder="8.4"
        returnKeyType="done"
        style={{ borderWidth: 1, borderColor: '#c9d3cc', borderRadius: 10, padding: 12, fontSize: 18 }}
      />
      <PrimaryButton label="Save" disabled={km === null} onPress={() => km !== null && onSave(km)} />
    </View>
  );
}

Notice what lives in state: the raw text, not a number. If you stored Number(text) instead, typing “8.” would immediately become 8 and the dot would vanish under the user’s thumb, and clearing the field would give you 0. Keep exactly what the user typed, and derive the parsed value during render. The button reads the derived value and stays disabled until it’s valid, which is clearer feedback than an error message that appears mid-typing.

The props that shape the mobile keyboard are worth knowing by name:

Prop What it does
keyboardType decimal-pad, number-pad, email-address, phone-pad, url
autoCapitalize none for emails and usernames; default capitalizes sentences
autoComplete / textContentType let the OS offer saved emails, passwords and one-time codes
returnKeyType the label on the return key: next, done, search
secureTextEntry hides the characters for passwords

Getting these right is most of what makes a form feel native. An email field that capitalizes the first letter, or a code field that doesn’t offer the one-time code from the SMS that just arrived, tells users the app wasn’t built with care.

Parsing what people type

Here is the idea behind parseDistanceKm: normalize first (trim, lower-case, drop a trailing “km”, turn a comma into a dot), then accept only a plain positive number within a sensible range.

JavaScript
function parseDistanceKm(text) {
  const cleaned = String(text).trim().toLowerCase().replace(/\s*km$/, '').replace(',', '.');
  if (!/^\d+(\.\d+)?$/.test(cleaned)) return null;
  const km = Number(cleaned);
  return km > 0 && km <= 200 ? km : null;
}

for (const input of ['8.4', '8,4', ' 12 km', 'abc', '0', '1.2.3']) {
  console.log(JSON.stringify(input), '->', parseDistanceKm(input));
}

Because it’s a plain function with no React Native imports, you can test it in milliseconds, which you’ll do in section 5. Keeping logic like this out of components is a habit that pays for itself on mobile, where running the app to check a case takes much longer than on the web.

That completes the core toolkit. Section 2 is about making it look right: styling without the cascade, flexbox with different defaults, and screens of every size.

Key takeaways

  • Pressable is the default for anything tappable; its style can be a function of { pressed } for instant feedback.
  • Tap targets should be at least 44 by 44 points; hitSlop enlarges the touch area without changing the layout.
  • A controlled TextInput uses value plus onChangeText, which receives the string directly.
  • keyboardType changes the keyboard, not the value: a TextInput always gives you a string, so parse and validate it yourself.

Further reading

Check your understanding

3 questions

Question 1 of 3

You set keyboardType="decimal-pad" on Trailhead’s distance input. What type is the value in onChangeText?

Question 2 of 3

Users say Trailhead’s small trash icon is hard to hit, but the design must stay 24 by 24 points. What’s the best fix?

Question 3 of 3

What’s wrong with this input?

TSX
const [name, setName] = useState('');
<TextInput value={name} onChange={setName} />

Practice

Code playground

Parse a hike's duration

Trailhead’s form has a Duration field with keyboardType="default", because hikers type durations in different ways. Write parseDurationMin(text) so it returns the duration in whole minutes, or null when the text isn’t valid.

Accept these shapes (ignoring case and surrounding spaces):

  • plain minutes: "135"
  • clock style: "2:15" (minutes must be below 60, so "2:75" is null)
  • hours and minutes: "2h 15m", "2h15m", "1h", "45m"

Return null for empty text, anything else, and for zero.

// Turn what a hiker types into whole minutes, or null if it isn't valid.
// Accept: "135", "2:15", "2h 15m", "2h15m", "1h", "45m" (any case, extra spaces OK).
function parseDurationMin(text) {
  return Number(text);
}

console.log(parseDurationMin('2h 15m'));
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • Plain numbers are minutes (“135” → 135)
  • Clock style works and rejects minutes ≥ 60
  • “2h 15m”, “2h15m” and “1H 5M” are parsed
  • “1h” → 60 and “45m” → 45
  • Empty, non-numeric and zero inputs return null