DEV Community

Famitha M A
Famitha M A

Posted on Originally published at fami-blog.hashnode.dev

The Design System of a Trustworthy React Native Checkout Screen

TL;DR

  • Checkout is the most emotionally loaded screen in your app. Design it like it.
  • Five zones: header, amount, line items, payment method, sticky Pay button.
  • Two type sizes. Not three. Not five.
  • No custom fonts on this screen. They delay the render of the total.
  • Never green on the Pay button. Never red outside errors.
  • The three states everyone skips: loading, error, success. Specs below.

Most React Native checkout screens I audit are functional and forgettable. A stack of <View> and <Text> in the app's default theme. The user taps through and either succeeds or bounces.

But this is where the user hands you money. Every pixel choice either builds trust or leaks it.

Here's the whole system, with code.

The five zones

┌─────────────────────────────┐
│  ← Back        Order summary │  Zone 1: Header (32pt height)
├─────────────────────────────┤
│                             │
│  Total                      │
│  $42.87 USD                 │  Zone 2: Amount (dominant type)
│                             │
├─────────────────────────────┤
│  Item 1              $30    │
│  Item 2              $10    │  Zone 3: Line items (collapsed)
│  Tax                 $2.87  │
├─────────────────────────────┤
│                             │
│  [ Apple Pay ]              │  Zone 4: Payment method
│  [ Card ending 4242 ]       │
│  [ + Add payment method ]   │
│                             │
├─────────────────────────────┤
│  [    Pay $42.87 USD    ]   │  Zone 5: Pay button (sticky)
└─────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Zone 5 stays sticky above the keyboard. Zone 2 collapses when the keyboard opens so the Pay button never leaves the viewport.

<KeyboardAvoidingView
  behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
  style={{ flex: 1 }}
>
  <ScrollView contentContainerStyle={{ flexGrow: 1 }}>
    <AmountBlock total={total} currency={currency} compact={keyboardVisible} />
    <LineItems items={items} />
    <PaymentMethods methods={methods} onSelect={setMethod} />
  </ScrollView>
  <PayButton total={total} currency={currency} onPress={handlePay} />
</KeyboardAvoidingView>
Enter fullscreen mode Exit fullscreen mode

Typography: two sizes, that's it

export const type = {
  amount: {
    fontSize: 32,
    fontWeight: '600' as const,
    letterSpacing: -0.5,
  },
  body: {
    fontSize: 15,
    fontWeight: '400' as const,
  },
};
Enter fullscreen mode Exit fullscreen mode

Platform system font only. SF Pro Display on iOS, Roboto on Android. Skip expo-font entirely on this screen. Custom font loading is real work on a cold boot, and anything that delays the render of the total is worth cutting. If you want a number for your own app, profile it instead of trusting a blog post.

Color tokens

import { PlatformColor, Platform } from 'react-native';

export const color = {
  background: Platform.select({
    ios: PlatformColor('systemBackground'),
    android: '#FFFFFF',
  }),
  text: Platform.select({
    ios: PlatformColor('label'),
    android: '#111111',
  }),
  // Brand primary only if it clears 4.5:1 on white. Otherwise black.
  payButton: contrastRatio(brandPrimary, '#FFFFFF') >= 4.5 ? brandPrimary : '#000000',
  errorBg: '#FFF4F4',
  errorText: '#B00020',
};
Enter fullscreen mode Exit fullscreen mode

Never use red for anything except errors. Never use green on the Pay button. Users associate green with "Buy" but not with "you're about to spend money," which is a subtle difference that still shows up in testing.

The three states everyone forgets

Loading. Swap the label, disable the button, and escalate the copy on a timer. An indeterminate spinner alone reads as a frozen app.

function usePayingLabel(processing: boolean) {
  const [elapsed, setElapsed] = useState(0);

  useEffect(() => {
    if (!processing) return setElapsed(0);
    const id = setInterval(() => setElapsed((e) => e + 1), 1000);
    return () => clearInterval(id);
  }, [processing]);

  if (!processing) return null;
  if (elapsed >= 8) return 'This is taking longer than usual, please wait.';
  if (elapsed >= 2) return 'Contacting your bank...';
  return null; // button label already says "Processing..."
}
Enter fullscreen mode Exit fullscreen mode

Error. Full-width bar above the Pay button. One human sentence, one recovery action. Never the raw API code.

{error && (
  <View style={{ backgroundColor: color.errorBg, padding: 12 }}>
    <Text style={{ color: color.errorText, fontSize: 15 }}>
      Your card was declined by your bank.
    </Text>
    <Pressable onPress={resetToPaymentMethod}>
      <Text style={{ color: color.errorText, fontWeight: '600' }}>Try again</Text>
    </Pressable>
  </View>
)}
Enter fullscreen mode Exit fullscreen mode

Success. Full-screen replacement: large checkmark, order ID, amount, timestamp, email receipt button. Hold 3 seconds minimum before any auto-dismiss so the user feels the transaction land.

The two components that carry the screen

// Pay button
<Pressable
  style={{
    height: 52,
    borderRadius: 12,
    backgroundColor: color.payButton,
    justifyContent: 'center',
    alignItems: 'center',
    marginHorizontal: 16,
  }}
  onPress={handlePay}
  disabled={!ready || processing}
>
  <Text style={{ color: 'white', fontSize: 17, fontWeight: '600' }}>
    {processing ? 'Processing...' : `Pay ${formatCurrency(total, currency)}`}
  </Text>
</Pressable>
Enter fullscreen mode Exit fullscreen mode
// Amount block
<View style={{ paddingHorizontal: 16, paddingVertical: 24 }}>
  <Text style={{ fontSize: 13, color: subtle, marginBottom: 4 }}>Total</Text>
  <Text style={type.amount}>
    {formatCurrency(total, currency)}{' '}
    <Text style={{ fontSize: 13, color: subtle }}>{currency}</Text>
  </Text>
</View>
Enter fullscreen mode Exit fullscreen mode

Note the 24pt of vertical padding above the total and the 16pt horizontal margin on the button. Those are the two anchor values on the 8pt grid. Density signals cheapness. Whitespace signals trust.

If you're scaffolding a fresh app rather than retrofitting one, RapidNative generates the screen structure and theme tokens up front, so you're wiring Stripe on day one instead of rebuilding the amount block by hand again.

Ship it in a sprint

  • Day 1: Audit the current checkout against the five zones. Find what's missing.
  • Day 2: Rebuild the amount zone and the Pay button. Do these first, they carry most of the perceived trust.
  • Day 3: Build the three states.
  • Day 4: Test with five people. Watch where they pause.
  • Day 5: Ship.

The rest is plumbing to Stripe or Razorpay.

What does your checkout look like right now? Drop a comment with the ugliest part of yours and I'll tell you which zone it belongs to.

Top comments (0)