Back to Blog
React NativeResponsiveDesign SystemMobileTypeScript

Responsive Design System for React Native Without Third-Party Libraries

Umut Korkmaz2025-03-158 min read

When I searched for responsive design solutions in React Native, every answer pointed to a library: react-native-responsive-screen, react-native-size-matters, or react-native-responsive-dimensions. But each added another dependency, another abstraction layer, and another package to audit for security in our regulated app. So I built a custom useResponsive() hook that handles everything we need in under 200 lines of code.

The Problem Space

React Native doesn't have CSS media queries. A button that looks perfect on an iPhone 15 Pro Max (430x932) looks cramped on an iPhone SE (375x667) and comically tiny on an iPad Pro (1024x1366). We needed a system that handles phone vs. tablet layouts, portrait vs. landscape orientation, dynamic font scaling based on screen dimensions, and consistent spacing across all device sizes.

The useResponsive() Hook

typescript
// hooks/useResponsive.ts
import { useWindowDimensions, PixelRatio, Platform } from 'react-native';
import { useMemo } from 'react';

// Base design dimensions (iPhone 14 Pro)
const BASE_WIDTH = 393;
const BASE_HEIGHT = 852;

interface DeviceInfo {
  isTablet: boolean;
  isLandscape: boolean;
  screenWidth: number;
  screenHeight: number;
  fontScale: number;
}

interface ResponsiveHelpers {
  wp: (percentage: number) => number;
  hp: (percentage: number) => number;
  fs: (size: number) => number;
  sp: (size: number) => number;
  device: DeviceInfo;
  select: <T>(options: { phone?: T; tablet?: T; landscape?: T }) => T;
}

export function useResponsive(): ResponsiveHelpers {
  const { width, height, fontScale } = useWindowDimensions();

  return useMemo(() => {
    const screenWidth = Math.min(width, height); // Always portrait width
    const screenHeight = Math.max(width, height); // Always portrait height
    const isLandscape = width > height;
    const isTablet = screenWidth >= 600;

    // Width percentage — responsive to screen width
    const wp = (percentage: number): number => {
      const value = (percentage / 100) * width;
      return PixelRatio.roundToNearestPixel(value);
    };

    // Height percentage — responsive to screen height
    const hp = (percentage: number): number => {
      const value = (percentage / 100) * height;
      return PixelRatio.roundToNearestPixel(value);
    };

    // Font size — scales relative to base design, respects accessibility
    const fs = (size: number): number => {
      const widthRatio = width / BASE_WIDTH;
      const heightRatio = height / BASE_HEIGHT;
      const scale = Math.min(widthRatio, heightRatio);

      // Clamp scaling between 0.8x and 1.4x to prevent extremes
      const clampedScale = Math.max(0.8, Math.min(1.4, scale));
      const scaledSize = size * clampedScale;

      // Respect system font scale for accessibility, but cap at 1.3x
      const accessibilityScale = Math.min(fontScale, 1.3);
      const finalSize = scaledSize * accessibilityScale;

      return PixelRatio.roundToNearestPixel(finalSize);
    };

    // Spacing — scales relative to base design without font scale
    const sp = (size: number): number => {
      const widthRatio = width / BASE_WIDTH;
      const scale = Math.max(0.8, Math.min(1.5, widthRatio));
      return PixelRatio.roundToNearestPixel(size * scale);
    };

    // Device-conditional rendering helper
    const select = <T,>(options: {
      phone?: T;
      tablet?: T;
      landscape?: T;
    }): T => {
      if (isLandscape && options.landscape !== undefined) {
        return options.landscape;
      }
      if (isTablet && options.tablet !== undefined) {
        return options.tablet;
      }
      return options.phone as T;
    };

    const device: DeviceInfo = {
      isTablet,
      isLandscape,
      screenWidth: width,
      screenHeight: height,
      fontScale,
    };

    return { wp, hp, fs, sp, device, select };
  }, [width, height, fontScale]);
}

Let me break down each helper.

wp() and hp(): Percentage-Based Dimensions

Instead of hardcoding pixel values, we express dimensions as percentages of the screen:

typescript
// Before — breaks on different screens
const styles = StyleSheet.create({
  card: {
    width: 350,     // Too wide on iPhone SE, too narrow on iPad
    height: 200,
    marginHorizontal: 20,
  },
});

// After — adapts to any screen
function AccountCard() {
  const { wp, hp, sp } = useResponsive();

  return (
    <View style={{
      width: wp(90),           // 90% of screen width
      height: hp(22),          // 22% of screen height
      marginHorizontal: sp(16),
      borderRadius: sp(12),
      padding: sp(16),
    }}>
      {/* content */}
    </View>
  );
}

On an iPhone SE (375px wide), wp(90) = 337.5px. On an iPad Pro (1024px wide), wp(90) = 921.6px. The card fills the screen appropriately on both devices.

fs(): Font Scaling Done Right

Font scaling is where most responsive systems get it wrong. They either scale linearly (text becomes unreadably small on small screens or absurdly large on tablets) or ignore accessibility settings entirely.

Our fs() function scales relative to the base design dimensions but clamps the scaling factor between 0.8x and 1.4x:

typescript
// Typography scale
const typography = {
  h1: (fs: Function) => ({ fontSize: fs(28), lineHeight: fs(36) }),
  h2: (fs: Function) => ({ fontSize: fs(22), lineHeight: fs(30) }),
  body: (fs: Function) => ({ fontSize: fs(16), lineHeight: fs(24) }),
  caption: (fs: Function) => ({ fontSize: fs(12), lineHeight: fs(18) }),
};

function TransactionAmount({ amount }: { amount: number }) {
  const { fs } = useResponsive();

  return (
    <Text style={{
      fontSize: fs(24),
      fontWeight: '700',
      lineHeight: fs(32),
    }}>
      {formatCurrency(amount)}
    </Text>
  );
}

The clamping prevents a 28px heading from becoming 22px on small screens (still readable) or 39px on tablets (proportional, not overwhelming). And we cap the accessibility font scale at 1.3x to prevent layout breakage while still respecting users who need larger text.

select(): Device-Conditional Logic

The select helper replaces scattered ternary operators with a clean declarative pattern:

typescript
function DashboardLayout({ children }: { children: React.ReactNode }) {
  const { select, wp, sp } = useResponsive();

  const columns = select({
    phone: 1,
    tablet: 2,
    landscape: 3,
  });

  const containerPadding = select({
    phone: sp(16),
    tablet: sp(32),
  });

  return (
    <View style={{ padding: containerPadding }}>
      <FlatList
        data={items}
        numColumns={columns}
        key={columns} // Force re-render when columns change
        columnWrapperStyle={
          columns > 1 ? { gap: sp(16) } : undefined
        }
        renderItem={({ item }) => (
          <View style={{ width: wp(100 / columns - 4) }}>
            <DashboardCard item={item} />
          </View>
        )}
      />
    </View>
  );
}

On a phone in portrait, you get a single-column list. Rotate to landscape: three columns. Open on a tablet: two columns. All without media queries.

Building a Responsive Design Token System

The hook alone isn't enough — you need design tokens that use it consistently:

typescript
// theme/useDesignTokens.ts
export function useDesignTokens() {
  const { sp, fs, select } = useResponsive();

  return useMemo(() => ({
    spacing: {
      xs: sp(4),
      sm: sp(8),
      md: sp(16),
      lg: sp(24),
      xl: sp(32),
      xxl: sp(48),
    },
    fontSize: {
      xs: fs(10),
      sm: fs(12),
      md: fs(14),
      lg: fs(16),
      xl: fs(20),
      xxl: fs(28),
    },
    borderRadius: {
      sm: sp(4),
      md: sp(8),
      lg: sp(16),
      full: sp(999),
    },
    hitSlop: {
      // Minimum 44pt touch targets (Apple HIG)
      default: select({
        phone: sp(44),
        tablet: sp(48),
      }),
    },
    iconSize: {
      sm: sp(16),
      md: sp(24),
      lg: sp(32),
    },
  }), [sp, fs, select]);
}

Components consume tokens instead of raw numbers:

typescript
function PrimaryButton({ label, onPress }: ButtonProps) {
  const tokens = useDesignTokens();

  return (
    <Pressable
      onPress={onPress}
      style={{
        backgroundColor: '#1a73e8',
        paddingVertical: tokens.spacing.md,
        paddingHorizontal: tokens.spacing.xl,
        borderRadius: tokens.borderRadius.md,
        minHeight: tokens.hitSlop.default,
        justifyContent: 'center',
        alignItems: 'center',
      }}
    >
      <Text style={{
        color: '#ffffff',
        fontSize: tokens.fontSize.lg,
        fontWeight: '600',
      }}>
        {label}
      </Text>
    </Pressable>
  );
}

Handling Orientation Changes

React Native's useWindowDimensions automatically triggers re-renders on orientation changes. Because our hook is memoized on width, height, and fontScale, everything recalculates automatically:

typescript
function App() {
  const { device, select } = useResponsive();

  return (
    <SafeAreaView style={{ flex: 1 }}>
      {select({
        phone: <MobileNavigation />,
        tablet: <TabletSidebarNavigation />,
      })}
      <MainContent />
      {device.isLandscape && <LandscapeToolbar />}
    </SafeAreaView>
  );
}

Why Not a Library?

Our custom hook is 80 lines of code with zero dependencies. The popular alternatives range from 500 to 2,000 lines and pull in additional packages. For a regulated app where every dependency is a security audit surface, smaller is better.

More importantly, we control the scaling algorithms. When our design team decided that fonts should never scale below 0.85x on small devices (previously 0.8x), it was a one-line change. Try negotiating that with a third-party library's release cycle.

The useResponsive() hook has been in production for over a year, handling devices from the iPhone SE to the iPad Pro 12.9-inch. It's not perfect — no responsive system is — but it's simple, predictable, and entirely under our control. Sometimes the best dependency is the one you don't install.