Mobile App UI with Tailwind: Bottom Nav, Cards, Gestures
Build a real mobile app UI with Tailwind v4 — bottom nav bars, swipeable cards, and touch gestures. Patterns that actually work on iOS and Android.
Tailwind for Mobile App UI Is Underrated
Honestly, most developers sleep on Tailwind when it comes to mobile app UIs. They reach for React Native or Expo right away, but if you're building a PWA or a mobile-first web app, Tailwind v4.0.2 gives you everything you need — and without the overhead of a native stack.
The real win here is that Tailwind's utility classes map almost 1:1 to the mental model of mobile layout. Safe areas, fixed bottom bars, full-bleed scroll containers — these translate cleanly into a handful of class names. You don't need a design system library to ship something that feels native.
This article walks through three specific patterns: a sticky bottom navigation bar, swipeable card stacks, and touch gesture feedback. Real code, real measurements, no hand-waving.
Setting Up a Mobile Viewport Container
Before anything else, you need a proper mobile shell. That means a full-height container that respects the iOS safe area, handles the virtual keyboard, and doesn't overflow horizontally. Tailwind's dvh support (dynamic viewport height) landed in v4 and it's the right unit here — h-dvh instead of h-screen so the nav doesn't get clipped when the keyboard opens.
Set overflow-hidden on the outer shell and overflow-y-auto on the scrollable content region. Add overscroll-none to kill the rubber-band bounce on Android WebView. These three classes alone fix about 80% of the "it looks wrong on mobile" complaints you'll run into.
For iOS safe areas, Tailwind v4 includes native support via pb-safe, pt-safe, etc., which map to the env(safe-area-inset-*) CSS variables. Make sure your tailwind.config.ts has content paths set correctly and you're running the Vite plugin — otherwise those classes won't generate.
Building a Sticky Bottom Navigation Bar
The bottom nav is the most recognizable mobile UI pattern. It's fixed, it has 4-5 icons, and the active state needs to be obvious. Here's a production-ready version using Tailwind v4.0.2 with a frosted-glass effect that reads well in both light and dark mode — you can pair this with the theme toggle pattern for React if you need full dark mode support.
import { Home, Search, Bell, User } from 'lucide-react';
const NAV_ITEMS = [
{ icon: Home, label: 'Home', href: '/' },
{ icon: Search, label: 'Search', href: '/search' },
{ icon: Bell, label: 'Alerts', href: '/alerts' },
{ icon: User, label: 'Profile', href: '/profile' },
];
export function BottomNav({ active }: { active: string }) {
return (
<nav
className="
fixed bottom-0 inset-x-0 pb-safe
flex items-center justify-around
h-16
bg-white/80 dark:bg-zinc-900/80
backdrop-blur-md
border-t border-zinc-200/60 dark:border-zinc-700/60
z-50
"
style={{ backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}
>
{NAV_ITEMS.map(({ icon: Icon, label, href }) => {
const isActive = active === href;
return (
<a
key={href}
href={href}
className={`
flex flex-col items-center gap-0.5 px-4 py-2 rounded-xl
transition-colors duration-150
${isActive
? 'text-violet-600 dark:text-violet-400'
: 'text-zinc-400 dark:text-zinc-500 active:text-zinc-700'
}
`}
>
<Icon size={22} strokeWidth={isActive ? 2.5 : 1.8} />
<span className="text-[10px] font-medium tracking-wide">{label}</span>
</a>
);
})}
</nav>
);
}The bg-white/80 with backdrop-blur-md gives you that frosted effect — same principle as glassmorphism in Tailwind but applied to navigation. The pb-safe class pushes the content above the iPhone home indicator. z-50 keeps it above modals and drawers.
Mobile Card Components with Proper Touch States
Cards on mobile need to feel responsive to touch. That means active: states, not just hover: states. On a touchscreen, hover doesn't fire reliably — or it fires at the wrong moment. Always design touch feedback using active:scale-[0.98] or active:opacity-80 instead of relying on hover.
The gap between cards matters a lot on mobile. 12px feels tight, 20px feels spacious. I land on gap-3 (12px) inside a scroll container for dense lists, and gap-4 (16px) for featured card grids. Padding inside cards should be at least p-4 — 16px — otherwise text gets too close to edges on small screens.
For card shadows on mobile, skip the big shadow-xl spreads. They look great on desktop but on OLED screens they can create weird banding. Instead, use shadow-sm plus a subtle border: border border-zinc-100 dark:border-zinc-800. Lightweight, renders cleanly across devices. If you want more depth, look at Tailwind component patterns for layered shadow techniques.
Swipeable Card Stacks with Touch Gestures
Swipe-to-dismiss and swipe-to-like patterns are everywhere in mobile apps. The cleanest way to implement them in a React + Tailwind setup is using the @use-gesture/react library alongside CSS transforms. Tailwind handles the static styles; gesture state drives the inline transforms.
Here's the pattern. You track dragX from the gesture hook, apply it as an inline transform: translateX(), and use Tailwind classes for everything else. The threshold for a full swipe is typically 30-40% of screen width — 120px on a 390px iPhone 15 display.
import { useDrag } from '@use-gesture/react';
import { useState } from 'react';
export function SwipeCard({ children, onSwipeLeft, onSwipeRight }: {
children: React.ReactNode;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
}) {
const [gone, setGone] = useState(false);
const [{ x, rotate }, set] = useState({ x: 0, rotate: 0 });
const bind = useDrag(({ down, movement: [mx], velocity: [vx] }) => {
const trigger = Math.abs(mx) > 120 || Math.abs(vx) > 0.5;
if (!down && trigger) {
const dir = mx > 0 ? 1 : -1;
set({ x: dir * 600, rotate: dir * 30 });
setGone(true);
dir > 0 ? onSwipeRight?.() : onSwipeLeft?.();
} else {
set({ x: down ? mx : 0, rotate: down ? mx / 12 : 0 });
}
}, { filterTaps: true, bounds: undefined });
if (gone) return null;
return (
<div
{...bind()}
className="
relative w-full rounded-2xl overflow-hidden
bg-white dark:bg-zinc-900
border border-zinc-200 dark:border-zinc-800
shadow-md cursor-grab active:cursor-grabbing
select-none touch-none
transition-opacity duration-200
"
style={{
transform: `translateX(${x}px) rotate(${rotate}deg)`,
transition: Math.abs(x) === 0 ? 'transform 0.35s ease' : 'none',
}}
>
{children}
</div>
);
}Two things worth noting: touch-none prevents the browser from intercepting the drag as a scroll gesture, and select-none stops text selection on fast swipes. Both are Tailwind v4 utilities that map directly to their CSS properties. Without them, the gesture feels sticky and broken on Android Chrome.
Scroll Containers and Pull-to-Refresh Patterns
Mobile scroll has its own quirks. The main content area between a top bar and bottom nav should use -webkit-overflow-scrolling: touch — but in Tailwind v4 you get scroll-smooth and overscroll-contain utilities that handle most of this. Set overscroll-y-contain on your scroll container so scrolling inside it doesn't accidentally trigger the browser's pull-to-refresh.
For horizontal scroll carousels — common in mobile app layouts — use flex overflow-x-auto snap-x snap-mandatory. Each item gets snap-start shrink-0. The snap utilities in Tailwind v4 are solid and handle iOS momentum scrolling correctly without any JS. Add scrollbar-none (or the scrollbar-hide plugin) so the scrollbar doesn't flash on desktop.
Want to see how container queries play into this? Cards inside a horizontal carousel can query their own width and switch from a compact to expanded layout without JS. It's one of the most useful combinations for adaptive mobile UIs.
Tailwind Color Tokens for Mobile UI Systems
Mobile UIs live and die by their color system. On small screens, contrast is everything — small text at text-xs (12px) needs a contrast ratio of at least 4.5:1 to be readable in sunlight. Tailwind's default palette mostly hits this, but you'll want to audit your specific combinations.
In Tailwind v4, you can define mobile-specific color tokens in your CSS config using OKLCH. Something like --color-accent: oklch(62% 0.22 280) for a violet that stays vivid across both P3 and sRGB displays. The OKLCH colors guide covers this in detail — especially useful if you're targeting newer iPhones with wide color gamut screens.
For overlays and modals on mobile, rgba(0,0,0,0.6) is the sweet spot for backdrop darkness. Lighter than that and content shows through too much; darker and it feels heavy. Use bg-black/60 in Tailwind — it compiles to exactly that value. For frosted panels, rgba(255,255,255,0.15) with backdrop-blur-xl gives a nice depth without killing performance on mid-range Android devices.
Performance and Animation on Mobile Devices
Animations on mobile need to be GPU-accelerated or they'll stutter. Stick to transform and opacity — never animate height, width, top, left, or margin. Tailwind's transition-transform and transition-opacity classes are safe. Anything that triggers layout recalculation will drop frames on budget phones.
The will-change-transform utility (available in Tailwind v4) hints to the browser to promote an element to its own compositor layer. Use it sparingly — on cards that you know will animate. Slapping it on everything backfires and increases memory pressure, which ironically causes more jank on low-memory devices.
For page transitions in a mobile web app, translate-x-full → translate-x-0 with a 300ms ease-out feels native. That's the same timing iOS uses for push navigation. Pair it with transition-transform duration-300 ease-out in Tailwind and you're close enough that most users won't notice the difference from a real native transition.
FAQ
Yes, and it works well. Tailwind v4.0.2 has utilities for safe area insets (pb-safe, pt-safe), dynamic viewport height (h-dvh), overscroll behavior, and snap scrolling. These cover the main mobile layout concerns. Pair with a Web App Manifest and a service worker and the result is indistinguishable from a native app for most use cases.
Use the pb-safe utility class in Tailwind v4, which maps to padding-bottom: env(safe-area-inset-bottom). Apply it to your bottom nav container. Make sure your HTML viewport meta tag includes viewport-fit=cover, otherwise env() returns 0 and the class has no effect.
Add touch-none (Tailwind) to the draggable element to suppress the browser's default touch handling. This tells the browser the element will handle touch events itself. You also want overscroll-none on parent scroll containers so a swipe that starts on the card doesn't accidentally scroll the page.
48x48px is the minimum recommended by both Apple HIG and Google Material Design. In Tailwind that's min-h-12 min-w-12 (48px = 3rem). For smaller icons, you can pad the clickable area without making the visual element bigger: use p-3 on a w-6 h-6 icon to get the right touch area.
Use dvh (dynamic viewport height) via h-dvh in Tailwind v4. The old 100vh unit doesn't account for the browser chrome on mobile — the address bar and navigation bar. dvh updates dynamically as those bars show or hide. h-dvh was added to Tailwind v4 core and doesn't require any plugins.
Add select-none to the swipeable card element. This sets user-select: none in CSS and prevents the browser from entering text-selection mode during a drag gesture. It's a single Tailwind class and solves one of the most annoying mobile UX bugs.