Responsive Design System for React Native Without Third-Party Libraries
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:
typescriptfunction 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:
typescriptfunction 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:
typescriptfunction 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.