Making a Phone App Feel Like Desktop on iPad — Two Layouts From One Width
One codebase: a tab bar on phones, a top bar with master-detail on iPad. The crash that orientation lock caused, the formatting toolbar that looked like it was floating, and splitting one width into two shapes.
Making a Phone App Feel Like Desktop on iPad — Two Layouts From One Width
fecit started as a phone app. A bottom tab bar, one screen at a time, navigation stacking upward. But the desktop web already looked different: a top bar with the logo and menus, a master-detail view showing the list on the left and the detail on the right.
The iPad sat in between. Blow up the phone app and the screen feels empty; drop in the desktop layout and it doesn’t fit touch. The same app had to wear a different body depending on screen size.
The criterion is width, not device
The first thing to settle was “when do we switch to the desktop shape.”
There was a temptation to split on Platform.isPad, but I didn’t. An iPad in split-view, using only half the screen, gets as narrow as a phone — and there the phone layout is correct. So I split on width, not device, mirroring the same 768px breakpoint the desktop web already used.
export const LARGE_SCREEN_BREAKPOINT = 768;
export function useIsLargeScreen() {
const {width} = useWindowDimensions();
return width >= LARGE_SCREEN_BREAKPOINT;
}
Because it uses useWindowDimensions, it responds to rotation, split-view, and Stage Manager resizing. A narrow split-view window falls back to the phone layout as intended, and if I turn on Android tablets later, the same code just works — the criterion is a single number.
Tab bar becomes a top bar
On phones, the bottom tab bar moves between five screens. On a big screen that feels off: the screen is wide, but your hand has to reach the bottom corner, and the top sits empty.
So on big screens I removed the bottom tab bar and replaced it with a top bar (AppTopBar) — the logo, a sub-view switcher, icon navigation, and that screen’s actions (search, create, add, menu) all in one row. Buttons that were scattered inside the screen on phones move up into plain icons on the top bar.
One screen at a time → list and detail side by side
The real work was the layout. On big screens, Tasks, Library, and Memo became master-detail: list on the left, detail on the right, plus a folder tree sidebar on the left edge.
On phones, tapping an item pushes a detail screen upward, and back returns you. On big screens the detail just opens in the right pane. Same detail component — on one side it slides up full-screen, on the other it’s embedded in a pane.
One tricky bit here: a detail embedded in a pane must not have a back button. Full-screen needs it, but a pane that’s always sitting there has nowhere to go back to. The same component had to know where it lived. So I added a context that signals “you’re inside a pane,” and hid the back button there.
Community and Me are a little different — menu-content rather than list-detail, so I split them into a left menu and right content.
The crash that orientation lock caused
The big-screen layout assumes landscape, so I locked the app landscape-only. That caused a crash.
The culprit was RN’s Modal. The app was locked to landscape, but a modal tried to open defaulting to portrait, and the two collided. When the locked orientation and the modal’s desired orientation don’t match, it just dies.
The fix was to declare supportedOrientations on each modal so it matches the app’s lock. I’d assumed orientation was a set-once thing, but it turns out a modal is a separate layer carrying its own orientation.
The toolbar that looked like it was floating
On big screens I added keyboard shortcuts (⌘ combos) for writing, since there’s a hardware keyboard. And I wanted a formatting toolbar (bold, italic, etc.) sticky above the description input.
At first the toolbar looked like a thin floating bar. It lived inside the input container’s padding (8px), so there was an empty strip between the label and the toolbar, and it didn’t reach the edges left or right. A small thing, but it nagged.
I stretched the toolbar to full width with negative margins, flushed it against the label, and put the 8px gap only below the toolbar to separate it from the input. After that I trimmed the height from 50 to 38 and the gap under the label from 8 to 4, making it more compact. “Add a toolbar” turned out to be measuring whitespace over and over.
One big cleanup
The biggest change during this work happened where you can’t see it. The Tasks tab file shrank by over 1,100 lines.
Since phone and big screen have to draw the same screen differently, cramming both layouts into one file makes it untouchable fast. I split common logic from layout and pulled the split layout, top bar, and sidebar each into their own place. In the end the branching collapses into one line — useIsLargeScreen — and the rest is each layout component’s responsibility.
What I learned
-
The criterion for responsive is width, not device. Split on
isPadand split-view breaks it. Split on width and one codebase covers phone, iPad, tablet, and resizing. -
Even the same detail component has to know where it landed. Full-screen gets a back button, a pane doesn’t. Tell it via context and the component handles itself.
-
An orientation lock doesn’t follow you into modals. Lock the app landscape and a modal still carries its own orientation. Without
supportedOrientations, the mismatch crashes. -
Whitespace never lands on the first try. “Add a toolbar” means re-measuring padding, margins, height, and gaps repeatedly. If it looks like it’s floating, it’s usually trapped inside a parent’s padding.
-
To let two layouts coexist, split first. Put both in one file and you can’t fix it. Collect the branch in one place, pull each layout out on its own.
I thought I’d built one phone app, but as the screen grew, the same app wanted a different body. In the end they were one skeleton, split by a single width.