Creating Responsive Web and Mobile Designs with Tamagui and Expo

It’s been a while since my last Medium post, and for good reason. Over the past two months, I’ve been revamping my Expo side project to…

Creating Responsive Web and Mobile Designs with Tamagui and Expo
X Web Layout

It’s been a while since my last Medium post, and for good reason. Over the past two months, I’ve been revamping my Expo side project to support web functionality. With Expo 52 emphasizing web support for React Native, I’ve explored tools to create responsive UI designs for both web and mobile. In this article, I’ll share components, and tips for building responsive layouts, following X’s web layout, using the powerful Tamagui UI library.

If you’re unfamiliar with Tamagui, check out my previous article.

Why Expo/React Native Developers Should Use Tamagui for Building Fast, Scalable UIs
As a React Native developer, you’re always looking for ways to streamline your development process and improve your…

It covers why I love Tamagui and how to set it up in a React Native app.

Initial Setup for Tamagui

Follow the setup guide from my previous article, or follow Tamagui’s Expo Guide, then apply these tweaks:

import { createAnimations } from "@tamagui/animations-react-native"; 
 
const animations = createAnimations({ 
  quick: { damping: 20, mass: 1.2, stiffness: 250 }, 
}); 
 
const config = createTamagui({ 
  themeClassNameOnRoot: true, 
  animations, 
  themes: { 
    light: { primary: "#228BE6", background: "#FFFFFF" }, 
    dark: { primary: "#228BE6", background: "#404040" }, 
  }, 
  media: createMedia({ xs: { maxWidth: 660 }, sm: { maxWidth: 800 } }), 
});

Update or create a metro.config.js:

const { getDefaultConfig } = require("expo/metro-config"); 
 
const config = getDefaultConfig(__dirname, { 
  isCSSEnabled: true, // Enables CSS support for web in Metro 
}); 
 
module.exports = config;

With Tamagui configured, let’s build a modern Expo web application. For inspiration, I’ll reference the X website, which uses Expo and a three-column layout: navigation, main content, and extra content. My side project follows a similar design, which is simple to implement and great for UI consistency.


PageContainer Component: A Reusable Wrapper

The PageContainer component ensures consistent padding, spacing, and background styles across your app’s pages. Here’s the code:

import React, { PropsWithChildren } from "react"; 
import { Platform } from "react-native"; 
import { GetProps, ScrollView, XStack, YStack, styled } from "tamagui"; 
 
const Container = styled(YStack, { 
  backgroundColor: "$background", 
  flex: 1, 
  padding: 15, 
  gap: "$5", 
  variants: { 
    padded: { false: { padding: 0 } }, 
  }, 
}); 
 
const ScrollContainer = styled(ScrollView, { 
  backgroundColor: "$background", 
  flex: 1, 
  padding: 15, 
  space: "$5", 
  showsVerticalScrollIndicator: false, 
  showsHorizontalScrollIndicator: false, 
  variants: { 
    padded: { false: { padding: 0 } }, 
  }, 
}); 
 
type PageContainerProps = GetProps<typeof Container> & 
  GetProps<typeof ScrollContainer> & { 
    scroll?: boolean; 
    rightColumn?: React.ReactNode; 
    columnWidths?: { leftColumnWidth: `${number}%`; rightColumnWidth: `${number}%` }; 
  }; 
 
export const PageContainer = (props: PropsWithChildren<PageContainerProps>) => { 
  const { scroll, columnWidths } = props; 
 
  if (Platform.OS === "web") { 
    return ( 
      <YStack flex={1} height={70}> 
        <XStack flex={1}> 
          <XStack width={columnWidths?.leftColumnWidth ?? "70%"}> 
            <ScrollContainer {...props}>{props.children}</ScrollContainer> 
          </XStack> 
          {props.rightColumn && ( 
            <YStack 
              width={columnWidths?.rightColumnWidth ?? "30%"} 
              height="100%" 
              borderLeftWidth={1} 
              borderLeftColor="$borderColor" 
              backgroundColor="$background" 
            > 
              {props.rightColumn} 
            </YStack> 
          )} 
        </XStack> 
      </YStack> 
    ); 
  } 
 
  return scroll ? ( 
    <ScrollContainer {...props}>{props.children}</ScrollContainer> 
  ) : ( 
    <Container {...props}>{props.children}</Container> 
  ); 
};

How It Works

  • Mobile: Uses Container (static) or ScrollContainer (scrollable) based on the scroll prop.
  • Web: Adds a two-column layout with customizable leftColumnWidth and rightColumnWidth percentages.

This handles the main and right content column from X’s design. This is also fully responsive since, we are using percentages for the widths of the columns! Next, let’s add navigation.

Here’s a NavigationContainer for a sidebar on web:

import { FolderKanban, Inbox, Notebook, Settings, Home } from "@tamagui/lucide-icons"; 
import { Image } from "expo-image"; 
import { Link, usePathname } from "expo-router"; 
import React, { PropsWithChildren } from "react"; 
import { Dimensions, Platform } from "react-native"; 
import { Paragraph, Spacer, Header as THeader, XStack, YStack, useTheme } from "tamagui"; 
import SideBarLabel from "./SideBarLabel"; 
 
const SIDE_BAR_WIDTH = 325; 
 
const NavigationContainer = ({ children }: PropsWithChildren) => { 
  const pathname = usePathname(); 
  const theme = useTheme(); 
 
  if (Platform.OS === "web") { 
    return ( 
      <div style={{ flex: 1, height: 61, width: "100%" }}> 
        <THeader 
          flex={1} 
          height={61} 
          paddingRight={50} 
          flexDirection="row" 
          alignItems="center" 
          backgroundColor="white" 
          borderBottomWidth={1} 
          borderBottomColor="#CCCCCC" 
        > 
          <XStack alignItems="center"> 
            <Link href="/" asChild> 
              <Image 
                source={{ uri: "your-logo-url" }} // Replace with your logo 
                style={{ width: 160, height: 80 }} 
                contentFit="contain" 
                priority="high" 
                cachePolicy="memory-disk" 
                transition={200} 
              /> 
            </Link> 
          </XStack> 
          <Spacer flex={1} /> 
          <Link href="/settings"> 
            <Paragraph>Settings</Paragraph> 
          </Link> 
        </THeader> 
        <XStack> 
          <YStack 
            height="100%" 
            width={SIDE_BAR_WIDTH} 
            $md={{ width: 70 }} 
            backgroundColor="darkgrey" 
            borderRightColor="#CCCCCC" 
            borderRightWidth={1} 
          > 
            <YStack padding={23} gap="$5"> 
              <SideBarLabel href="/" selected={pathname === "/"} label="Home" icon={Home} /> 
              {/* Add more SideBarLabel components for other navigation items */} 
            </YStack> 
          </YStack> 
          <YStack flex={1}>{children}</YStack> 
        </XStack> 
      </div> 
    ); 
  } 
  return <>{children}</>; 
}; 
 
export default NavigationContainer;

SideBarLabel Component

import { Link, router } from "expo-router"; 
import React, { useMemo } from "react"; 
import { Paragraph, Anchor, XStack, useGetThemedIcon } from "tamagui"; 
 
type SideBarLabelProps = { 
  label: string; 
  icon: any; // Icon component or JSX element 
  selected: boolean; 
  href: string; 
}; 
 
const SideBarLabel = ({ label, icon, selected, href }: SideBarLabelProps) => { 
  const resolvedHref = useMemo(() => (href ? Link.resolveHref(href) : undefined), [href]); 
 
  return ( 
    <Anchor 
      href={resolvedHref} 
      hoverStyle={{ cursor: "pointer" }} 
      pressStyle={{ opacity: 0.4 }} 
      onPress={(e) => { 
        e.preventDefault(); 
        if (resolvedHref) router.push(href); 
      }} 
      textDecorationLine="none" 
    > 
      <XStack gap="$4" alignItems="center"> 
        <SideBarIcon selected={selected}>{icon}</SideBarIcon> 
        <Paragraph color={selected ? "black" : "grey"}>{label}</Paragraph> 
      </XStack> 
    </Anchor> 
  ); 
}; 
 
const SideBarIcon = ({ children, selected }: { children: any; selected: boolean }) => { 
  const getThemedIcon = useGetThemedIcon({ size: 25, color: selected ? "black" : "grey" }); 
  const themedIcon = getThemedIcon(children); 
  return themedIcon || null; 
}; 
 
export default SideBarLabel;

Integration in _layout.tsx

import { Platform } from "react-native"; 
import { NavigationContainer } from "./NavigationContainer"; 
import { Stack } from "expo-router"; 
 
const Layout = () => ( 
  <NavigationContainer> 
    <Stack screenOptions={{ headerShown: Platform.OS !== "web" }}> 
      <Stack.Screen name="(tabs)" /> 
      {/* Add other screens here */} 
    </Stack> 
  </NavigationContainer> 
); 
 
export default Layout;

This gives you a three-column layout on web (navigation, main, extra) and a simple PageContainer on mobile.

Sheet Component: Mobile vs. Web

I use @gorhom/bottom-sheet for mobile sheets (e.g., filters) but switch to Tamagui’s Dialog for web due to rendering issues. Here’s a unified Sheet component:

import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from "react"; 
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetView } from "@gorhom/bottom-sheet"; 
import { Dialog, YStack, useTheme } from "tamagui"; 
import { Platform } from "react-native"; 
import ModalHeader from "../ModalHeader"; 
import { PageContainer } from "../PageContainer"; 
 
export type SheetRef = { hide: () => void; show: () => void }; 
 
type SheetProps = { 
  children: React.ReactNode; 
  title: string; 
  snapPoint: number; 
  padded?: boolean; 
  webWidth?: number; 
}; 
 
const Sheet = forwardRef<SheetRef, SheetProps>( 
  ({ children, title, snapPoint, padded = true, webWidth = 450 }, ref) => { 
    const [open, setOpen] = useState(false); 
    const sheetRef = useRef<BottomSheetModal>(null); 
    const theme = useTheme(); 
 
    useImperativeHandle(ref, () => ({ 
      hide: () => { 
        sheetRef.current?.forceClose(); 
        setOpen(false); 
      }, 
      show: () => { 
        sheetRef.current?.present(); 
        setOpen(true); 
      }, 
    })); 
 
    const snapPoints = useMemo(() => [`${snapPoint}%`], [snapPoint]); 
    const renderBackdrop = useCallback( 
      (props: any) => <BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} />, 
      [] 
    ); 
 
    if (Platform.OS === "web") { 
      return ( 
        <Dialog open={open} onOpenChange={setOpen}> 
          <Dialog.Portal> 
            <Dialog.Overlay 
              backgroundColor="#555" 
              opacity={0.7} 
              animation={["quick", { opacity: { overshootClamping: true } }]} 
              enterStyle={{ opacity: 0 }} 
              exitStyle={{ opacity: 0 }} 
            /> 
            <Dialog.Content 
              padding={0} 
              borderRadius={10} 
              overflow="hidden" 
              borderWidth={0} 
              animateOnly={["transform", "opacity"]} 
              animation={["quick", { opacity: { overshootClamping: true } }]} 
              enterStyle={{ x: 0, y: -20, opacity: 0, scale: 0.9 }} 
              exitStyle={{ x: 0, y: 10, opacity: 0, scale: 0.95 }} 
              shadowOpacity={0} 
            > 
              <YStack backgroundColor="$background" maxHeight={750} width={webWidth}> 
                <ModalHeader title={title} onClose={() => setOpen(false)} /> 
                <YStack flex={1} padding={15}> 
                  {children} 
                </YStack> 
              </YStack> 
            </Dialog.Content> 
          </Dialog.Portal> 
        </Dialog> 
      ); 
    } 
 
    return ( 
      <BottomSheetModal 
        ref={sheetRef} 
        backdropComponent={renderBackdrop} 
        snapPoints={snapPoints} 
        containerStyle={{ backgroundColor: theme.background.val }} 
        handleIndicatorStyle={{ backgroundColor: "white" }} 
        style={{ backgroundColor: theme.background.val, borderRadius: 13 }} 
      > 
        <BottomSheetView style={{ flex: 1 }}> 
          <PageContainer backgroundColor="$background" padded={padded}> 
            {children} 
          </PageContainer> 
        </BottomSheetView> 
      </BottomSheetModal> 
    ); 
  } 
); 
 
export default Sheet;

ModalHeader Component

import { X } from "@tamagui/lucide-icons"; 
import React from "react"; 
import { Paragraph, Square, XStack } from "tamagui"; 
 
type ModalHeaderProps = { title: string; onClose: () => void }; 
 
const ModalHeader = ({ title, onClose }: ModalHeaderProps) => ( 
  <XStack width="100%" padding={20} alignItems="center"> 
    <Paragraph flex={1} fontSize={24} lineHeight={24} fontWeight="bold"> 
      {title} 
    </Paragraph> 
    <Square 
      backgroundColor="$frameBackground" 
      pressStyle={{ opacity: 0.4 }} 
      padding={5} 
      cursor="pointer" 
      borderRadius={5} 
      onPress={onClose} 
    > 
      <X color="white" /> 
    </Square> 
  </XStack> 
); 
 
export default ModalHeader;

Sheet Usage Example

import { useRef } from "react"; 
import { Paragraph } from "tamagui"; 
import Sheet, { SheetRef } from "./Sheet"; 
import { PageContainer } from "./PageContainer"; 
 
const Screen = () => { 
  const sheetRef = useRef<SheetRef>(null); 
 
  const handlePress = () => sheetRef.current?.show(); 
 
  return ( 
    <Sheet ref={sheetRef} title="Test Sheet" snapPoint={70}> 
      <PageContainer> 
        <Paragraph>Test</Paragraph> 
      </PageContainer> 
    </Sheet> 
  ); 
}; 
 
export default Screen;

Tooltip Component: Web-Only Enhancement

Add tooltips for web with Tamagui:

import React, { PropsWithChildren } from "react"; 
import { Paragraph, Tooltip, YStack } from "tamagui"; 
 
const ToolTip = ({ children, content, onPress }: PropsWithChildren<{ content: string; onPress: () => void }>) => ( 
  <Tooltip placement="bottom" delay={{ open: 0, close: 0 }}> 
    <Tooltip.Trigger> 
      <YStack 
        padding={10} 
        borderRadius={5} 
        hoverStyle={{ backgroundColor: "#d9d9de" }} 
        cursor="pointer" 
        onPress={onPress} 
      > 
        {children} 
      </YStack> 
    </Tooltip.Trigger> 
    <Tooltip.Content 
      enterStyle={{ x: 0, y: -5, opacity: 0, scale: 0.9 }} 
      exitStyle={{ x: 0, y: -5, opacity: 0, scale: 0.9 }} 
      animation={["quick", { opacity: { overshootClamping: true } }]} 
      themeInverse 
      paddingHorizontal={14} 
      paddingVertical={12} 
    > 
      <Tooltip.Arrow /> 
      <Paragraph>{content}</Paragraph> 
    </Tooltip.Content> 
  </Tooltip> 
); 
 
export default ToolTip;

Wrap any component with ToolTip to show a hover tooltip on web.

Real-Life Example: BanKan Side Project

BanKan Mobile & Web Layout

My side project BanKan, brings these concepts to life using the components above. On the web, it features a three-column layout navigation, main content, and extra content replacing the mobile tab bar. For instance, instead of displaying “Tasks Due by Mar 24th” in the main column, I shifted it to the right column. To keep the mobile experience clean, I added a simple Platform.OS !== “web” check to hide these tasks on web. This demonstrates how reusable Tamagui components streamline responsive design across web and mobile platforms.

Conclusion

This isn’t a full tutorial to recreate X’s website, but these Tamagui components PageContainer, NavigationContainer, Sheet, and ToolTip simplify building responsive designs for web and mobile. They’ve saved me time converting my mobile app to a web app, and I hope they help you too!

Try BanKan Board — The Project Management App Made for Developers, by Developers

If you’re tired of complicated project management tools with story points, sprints, and endless processes, BanKan Board is here to simplify your workflow. Built with developers in mind, BanKan Board lets you manage your projects without the clutter.

Key Features:

  • No complicated processes: Focus on what matters without the overhead of traditional project management systems.
  • Claude AI Assistant: Get smart assistance to streamline your tasks and improve productivity.
  • Free to Use: Start using it without any upfront cost.
  • Premium Features: Upgrade to unlock advanced functionality tailored to your team’s needs.

Whether you’re building a side project, managing a team, or collaborating on open-source software, BanKan Board is designed to make your life easier. Try it today!

Get Started with BanKan Board — It’s Free!