There is an enormous gap between "it works on a phone" and "it feels like a native app." Our mobile CRM rendered correctly after the first responsive build. Every element was visible, every button was tappable, every page loaded. But using it felt wrong. The kind of wrong where you cannot point to a single broken element, but the overall experience screams "this is a desktop app shrunk down to a phone screen."
Making it feel native took 7 additional commits focused entirely on positioning, padding, and interaction details. None of these commits added new features. None of them changed business logic. They were pure craft, the kind of work that separates a PWA people tolerate from one they actually choose to use every day. This post walks through each commit, why it mattered, and what we learned about mobile UX in the process.
The difference between "works on mobile" and "feels native" is measured in pixels and milliseconds. No single fix is dramatic. The cumulative effect is transformative.
When you tap an input field on mobile, the virtual keyboard slides up and covers roughly 40% of the viewport. On iOS Safari, the browser's visualViewport API reports the new visible area, but the page layout does not automatically adjust. The result is that the input field you just tapped scrolls behind the keyboard and disappears. You are typing blind.
Our fix listens to the visualViewport.resize event and calculates the difference between the window's outer height and the visual viewport height. That difference is the keyboard height. We apply it as padding-bottom to the scrollable container, then call scrollIntoView({ block: 'center' }) on the focused input. The input stays centered in the visible area above the keyboard. This is especially critical for our AI chat interface, where users type long natural-language queries and need to see what they are writing.
Mobile browsers have their own UI elements that overlap your content. Safari on iOS has a floating URL bar at the bottom. Chrome on Android has a toolbar at the top that shrinks as you scroll. Both of these eat into your viewport in ways that 100vh does not account for. We had deal cards cut off at the bottom of the screen and header buttons partially hidden behind the Android status bar.
The fix uses CSS env(safe-area-inset-top) and env(safe-area-inset-bottom) combined with 100dvh (dynamic viewport height) instead of 100vh. The dynamic viewport unit adjusts as the browser chrome appears and disappears, so our layout always fills exactly the available space. We also added a scroll padding to ensure that when users scroll to the bottom of a list, the last item is fully visible above the bottom navigation bar. Small detail, but without it, users have to scroll past the end and hold the position to read the last contact in a list.
Desktop UIs can be sloppy with z-index because mouse interactions are precise and overlapping elements rarely matter. On mobile, overlapping elements are a usability disaster. We had three overlapping layers that needed strict ordering: the deal card list at the base, the stage picker overlay when you long-press a card, and the confirmation modal above the stage picker. On desktop, all three rendered fine. On mobile, the stage picker appeared behind the card it was supposed to modify.
The root cause was that our card component created a new stacking context with transform: translateZ(0) (a common GPU acceleration trick), which trapped the stage picker inside the card's stacking context regardless of its z-index value. We moved the stage picker to a portal that renders at the document root, outside any stacking context. The modal already used a portal, so the layering became: cards (z-auto) < stage picker portal (z-index: 100) < modal portal (z-index: 200). We created a z-index scale file that defines named constants for every layer in the app, preventing ad-hoc z-index wars as we add features.
The iPhone notch and Dynamic Island take a bite out of the screen that no amount of CSS can reclaim. If you ignore safe areas, your header text slides behind the notch in landscape orientation, and your bottom navigation overlaps the home indicator gesture area. Android devices with rounded corners and camera cutouts have similar issues, though the affected areas are smaller.
We added viewport-fit=cover to our viewport meta tag, which tells the browser to extend the page into the safe area insets. Then we use env(safe-area-inset-*) in our CSS to add padding where needed. The header gets top inset padding. The bottom tab bar gets bottom inset padding. The pipeline view gets left and right inset padding for landscape use. This commit also fixed an issue where the swipe gestures on deal cards extended into the safe area and interfered with the iOS back swipe gesture.
Chrome 56 introduced a change where touch event listeners on the document and window are assumed to be passive by default. If your listener calls preventDefault(), Chrome logs a console warning and may ignore the call, resulting in scroll jank. We had three non-passive touch listeners left over from our drag-and-drop implementation for deal cards. Each one triggered the warning, and the scroll performance was noticeably choppy on mid-range Android devices.
The fix was surgical. For the two listeners that did not actually need preventDefault(), we added { passive: true } to the event registration. For the one that did need it (the horizontal swipe handler that prevents vertical scroll while swiping), we kept it non-passive but moved it from the document level to the specific card element. This meant Chrome only blocked scroll prevention on the card being swiped, not on the entire page. The result was buttery-smooth scrolling everywhere except during an active swipe gesture, which is exactly the behavior we wanted.
Our bottom tab bar (Contacts, Deals, Phone, AI) needs to sit in the same position on every device. On phones without a home indicator, it should sit flush at the bottom. On phones with a home indicator (every modern iPhone), it needs extra bottom padding so the tap targets do not overlap the system gesture area. On Android devices with gesture navigation enabled, the same logic applies but with different inset values.
We used a combination of env(safe-area-inset-bottom) for the padding and position: fixed with bottom: 0 for the positioning. The tab bar's height is a fixed 56px plus whatever the safe area inset adds. We then offset the main content area's bottom padding by the same amount so that the last item in any scrollable list is never hidden behind the tab bar. Testing this required checking on an iPhone SE (no Dynamic Island, small screen), iPhone 15 Pro (Dynamic Island, larger inset), and a Pixel 7 (Android gesture navigation). Each device rendered the tabs correctly after this commit.
Apple's Human Interface Guidelines specify a minimum 44x44 point tap target for all interactive elements. Google's Material Design guidelines recommend 48x48dp. We had several elements that fell short: the star icon for favoriting a contact (28x28px), the filter chip close button (24x24px), and the pipeline stage indicator dots (20x20px). None of these were difficult to tap on a desktop with a mouse cursor. All of them were frustrating on a phone with a thumb.
Rather than enlarging the visible icons (which would break our Linear/Attio-inspired design), we expanded the tappable area using padding and transparent hit areas. The star icon remains 28px visually but has a 44x44px invisible touch target around it. The filter chip close button uses the same technique. The pipeline dots got a slightly larger visual treatment (24px) with 48px touch targets. We also audited every button, link, and interactive element in the mobile UI and standardized on a minimum 44px touch target, documented in our design system tokens. This single commit eliminated more user frustration than any feature we shipped that month.
The funnel view deserves special mention because it is the feature where all seven commits converge into a single experience. The funnel uses a Linear/Attio-inspired minimal design with 3px accent bars per pipeline stage. Each card shows the deal name, value, and company. The cards are swipeable for quick stage changes. The stage picker overlays correctly. The keyboard padding works when you tap to edit a deal name inline. The bottom tabs stay clear of the home indicator.
The result is a pipeline view that looks and feels like a native iOS app. Users who test SalesSheet on mobile consistently mention the pipeline funnel as the moment they realize this is not just a responsive website. It is a real mobile CRM built for phones. And it got there not through some grand redesign, but through seven focused commits that each fixed one thing precisely right. That is the unglamorous truth about mobile polish: it is not one big fix, it is dozens of tiny ones, and the only way to find them is to use your own product on a real phone, every day, and fix what annoys you.