← Back to list

This Scalable Card Component Fixed My Messy React Native UI

My UI became easier to scale once I started thinking in systems

Abdul Basit in Stackademic · 2026-02-24 17:19 · 56 claps · 7.4 min read
#reactjs #react-native #mobile-apps #ui-development #architecture
Open on Medium ↗
Wiki topics: 🌐 · Web Development 📱 · Mobile Development 🏛️ · Architecture

This Scalable Card Component Fixed My Messy React Native UI

My UI became easier to scale once I started thinking in systems

The image is created by Author using app screenshots and AI

The image is created by Author using app screenshots and AI

Let me tell you about a mistake I was making in my new React Native app.

I was building an app, let's say a coffee shop app. It had many screens and components, including product cards, review cards, settings cards, and stats cards.

Every time I needed a new screen or a card, I opened AI tool, gave it a context, and asked for a “nice design”. Paste the AI generated code result by adjusting a few things.

Sounds fine, right?

It wasn’t.

This approach creates 2 issues.

Inconsistent Design

After a few days, I opened the project to work on a new feature, and I realized something was wrong.

Everything still worked. But it just didn’t feel right.

The design of app was inconsistent.

Then I dug deep, and I figured it out.

Every card in my app was slightly different from the others:

  • One card had borderRadius: 12. Another had borderRadius: 16.
  • One used padding: 16. Another used padding: 12.
  • Background colors were all over the place, #fff here, #F9FAFB there, white somewhere else.
  • Shadows? Each card had its own thing going on.

Each card looked fine on its own. But together? The app looked like it was built by five different people who never talked to each other.

That’s when I realized I wasn’t building a design system. I was just building screens and hoping for the best.

Why Does This Happen?

When we try to build fast, we just want things to work.

When I need a new card. I copy one from another screen, change a few styles, and ship it.

And if you’re using AI to help you build UI (like I was), the problem gets even bigger. AI is great at building UI. But it doesn’t know your app. It doesn’t know your design has borderRadius: 16. It doesn't know your shadow color. So every time you ask it to build a card or UI, it makes up its own style.

The result? An inconsistent app where Everything works, but nothing matches.

Dark Mode Becomes a Nightmare

The second problem was that it became difficult to implement dark mode efficiently.

I decided to add dark mode.

First, it seems easy, just swap some colors.

But due to my current workflow, it wasn’t that easy.

Here’s what actually happened:

  1. I opened the first card. It had backgroundColor: '#fff' written directly in the code.
  2. I opened the second card. Same thing, but a different color value.
  3. The third card had its border color hardcoded too.
  4. The fourth card had its own shadow settings.

Every single card had its own hardcoded styles.

To add dark mode, I had to go into every card one by one, change the background, fix the border, adjust the shadow, and test it all again. It took forever. And I kept missing things.

That’s when I asked myself: Why don’t I have one single Card component that defines my design system, and I use it everywhere?

React Native Card UI

In every serious app, we build reusable components, for examples one Button component and use it everywhere. We don't write a brand new button with its own styles on every screen.

[embed]The Reusable Button Design System I Use in Every React Native App Learn to build production-ready React Native button UI component using scalable UI patternsjavascript.plainenglish.io

Same with TextInput. One component, used everywhere.

So why are we building a new card from scratch every time?

Cards are probably the most common UI element in any mobile app. If you’re not controlling them from one place, your design will slowly fall apart, and you won’t even notice until it’s too messy.

The fix is simple:

One Card wrapper component for all cards in your app.

Let’s Build It

Here’s what I wanted my Card component to do:

  • Control the background color
  • Control the border and rounded corners
  • Control the spacing inside the card (padding)
  • Handle shadows properly on both iOS and Android
  • Become tappable when needed

Here’s the full component code:

import { ReactNode } from 'react'
import { Platform, Pressable, StyleSheet, View, ViewStyle } from 'react-native'

type CardVariant = 'outlined' | 'filled' | 'elevated'
type CardPadding = 'none' | 'small' | 'medium' | 'large'

type CardProps = {
  children: ReactNode
  variant?: CardVariant
  padding?: CardPadding
  onPress?: () => void
  style?: ViewStyle
  disabled?: boolean
  testID?: string
}

const COLORS = {
  card: '#FFFFFF',
  border: '#E5E7EB',
  filled: '#F9FAFB',
  pressed: '#F3F4F6',
  shadow: '#64748b',
}

const PADDING: Record<CardPadding, number> = {
  none: 0,
  small: 12,
  medium: 16,
  large: 20,
}

export default function Card({
  children,
  variant = 'outlined',
  padding = 'medium',
  onPress,
  style,
  disabled = false,
  testID,
}: CardProps) {
  const baseStyle: ViewStyle = {
    borderRadius: 16,
    padding: PADDING[padding],
    overflow: 'hidden',
  }

  const variantStyles: Record<CardVariant, ViewStyle> = {
    outlined: {
      backgroundColor: COLORS.card,
      borderWidth: 1,
      borderColor: COLORS.border,
    },
    filled: {
      backgroundColor: COLORS.filled,
    },
    elevated: {
      backgroundColor: COLORS.card,
      ...Platform.select({
        ios: {
          shadowColor: COLORS.shadow,
          shadowOffset: { width: 0, height: 4 },
          shadowOpacity: 0.12,
          shadowRadius: 12,
        },
        android: {
          elevation: 6,
          shadowColor: COLORS.shadow,
          shadowOffset: { width: 0, height: 2 },
          shadowOpacity: 0.25,
        },
      }),
    },
  }

  const disabledStyle: ViewStyle = disabled ? { opacity: 0.5 } : {}

  if (onPress) {
    return (
      <Pressable
        testID={testID}
        disabled={disabled}
        onPress={onPress}
        style={({ pressed }) => [
          baseStyle,
          variantStyles[variant],
          pressed && styles.pressed,
          disabledStyle,
          style,
        ]}
      >
        {children}
      </Pressable>
    )
  }

  return (
    <View testID={testID} style={[baseStyle, variantStyles[variant], disabledStyle, style]}>
      {children}
    </View>
  )
}

const styles = StyleSheet.create({
  pressed: {
    opacity: 0.7,
    transform: [{ scale: 0.98 }],
  },
})

Now, let me explain the important parts in simple terms.

1. The variant prop — Three card styles

Instead of every card inventing its own random styles, you pick from three options:

  • **outlined** — White background with a light grey border. Great for settings rows or list items.
  • **filled** — Light grey background, no border. Good for subtle, low-key cards.
  • **elevated** — White background with a shadow underneath. Perfect for product cards or anything you want to pop off the screen.

2. The padding prop — Spacing tokens

Instead of guessing a random number, you pick a name:

  • none → 0px inside the card
  • small → 12px
  • medium → 16px (the default)
  • large → 20px

No more padding: 14 in one card and padding: 15 in another. Everyone picks from the same list.

3. The onPress prop — Tappable cards

Pass an onPress and the card becomes tappable. It even shrinks slightly when pressed (that's the scale: 0.98 at the bottom).

4. Shadows on iOS and Android

Here’s something that trips a lot of people up. Shadows work completely differently on iOS and Android:

  • iOS needs: shadowColor, shadowOffset, shadowOpacity, shadowRadius
  • Android needs: just elevation (and optionally shadowColor)

If you write shadow code for one platform, it breaks on the other. The Platform.select inside the elevated variant handles both automatically. You pick variant="elevated" and it just looks right everywhere.

5. The style prop — For custom sylting

The component gives you good defaults, but you can always override them.

If one specific card needs something custom, a different margin, or a specific width, you pass a style prop, and it gets layered on top.

Before vs After

Here’s what my product card looked like before I had the Card component:

<View
  style={{
    backgroundColor: '#fff',
    padding: 16,
    borderRadius: 12,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 8,
    elevation: 4,
  }}
>
  <Image source={product.image} />
  <Text>{product.name}</Text>
  <Text>{product.price}</Text>
</View>

And after:

<Card variant="elevated">
  <Image source={product.image} />
  <Text>{product.name}</Text>
  <Text>{product.price}</Text>
</Card>

Did you observe that the code becomes cleaner?

But the real benefit is when you need to change something, it’s no longer stressful.

Want to update the border radius for every card in the app? Change it in one place.

Want stronger or softer shadows? Adjust a single value.

Want to support dark mode? Update one color object.

Just one change and the whole app UI updates.

But What If I Need Different Cards?

Now you may think,

“Won’t one Card component make everything look the same?”

No.

Having one component doesn’t mean every card becomes identical. It just means the structure and default behavior stay consistent. The shape, spacing system, and styling rules are controlled in one place.

You handle those intentionally through props.

In my coffee shop app, for example, I use the same Card component everywhere, but I adjust it depending on the need.

Here’s one case:

A full-image banner, no inner padding needed:

<Card variant="elevated" padding="none">
  <Image source={banner.image} style={{ width: '100%', height: 200 }} />
</Card>

A settings row with a simple border:

<Card variant="outlined" padding="small">
  <SettingsRow label="Notifications" />
</Card>

A stats card that opens a new screen when tapped:

<Card variant="filled" onPress={() => router.push('/stats')}>
  <StatDisplay value={totalOrders} label="Total Orders" />
</Card>

The variations are intentional. If you like, you can add more.

Making Dark Mode Actually Easy

It also solved our dark mode or multiple theme problem.

Remember how adding dark mode felt like a work even for AI (waste tokens)? Here’s how the Card component makes it a non-issue.

Step 1 — Create a small theme hook:

// useTheme.ts
import { useColorScheme } from 'react-native'

export function useTheme() {
  const colorScheme = useColorScheme()

  return {
    card: colorScheme === 'dark' ? '#1F2937' : '#FFFFFF',
    border: colorScheme === 'dark' ? '#374151' : '#E5E7EB',
    filled: colorScheme === 'dark' ? '#111827' : '#F9FAFB',
    shadow: '#64748b',
  }
}

Step 2 — Use it inside the Card component:

export default function Card({ children, variant = 'outlined', padding = 'medium', ...rest }: CardProps) {
  const colors = useTheme() // 👈 reads light or dark theme automatically

  const variantStyles: Record<CardVariant, ViewStyle> = {
    outlined: {
      backgroundColor: colors.card,   // updates automatically
      borderWidth: 1,
      borderColor: colors.border,     // updates automatically
    },
    filled: {
      backgroundColor: colors.filled, // updates automatically
    },
    elevated: {
      backgroundColor: colors.card,
      // ... shadows stay the same
    },
  }

  // rest of the component stays exactly the same
}

Now, when the user switches to dark mode, every card in your entire app updates on its own. No hunting through screens.

That’s the whole point of having one reusable component.

Context for AI

Here’s one more benefit of it.

Once I had the Card component, AI became way more useful for building screens.

Before, when I asked AI to “build a product card,” it would make up its own styles, which were different every time.

But now I tell it: “Build a product card using our Card component with variant='elevated'."

And it does exactly that. It works inside my system instead of inventing its own, and the output just fits.

AI works best when you give it a clear structure. Without it, AI guesses and creates inconsistency. With proper building blocks like a Card component, it follows your system instead of inventing new styles, and that’s when it truly becomes helpful.

When your app is small, none of this feels important. You copy a style, change a color, ship it. Life is good.

But often apps don’t stay small. You keep adding new screens, new features, and new sections. And every hardcoded style you write today becomes something you’ll have to search for and fix later. The more you delay organizing it, the bigger and messier it becomes.

Did you learn something new? Let me know in the comment box.


메타데이터
post_id
7e4e5cdda2ac
slug
my-react-native-ui-was-a-mess-until-i-built-this-one-card-component-7e4e5cdda2ac
url
https://blog.stackademic.com/my-react-native-ui-was-a-mess-until-i-built-this-one-card-component-7e4e5cdda2ac
canonical_url
https://blog.stackademic.com/my-react-native-ui-was-a-mess-until-i-built-this-one-card-component-7e4e5cdda2ac
author_url
https://medium.com/@basit.miyanjee
status
ok
fetched_at
2026-06-09 15:37:30