On desktop, detail panels slide in from the right. On mobile, everything is a bottom sheet — a draggable panel that rises from the bottom of the screen. The challenge isn’t building one bottom sheet. It’s building a system where sheets can nest transparently, each new sheet scaling down the one beneath it, creating the familiar iOS-style card stack.
Anatomy of a bottom sheet
A bottom sheet is a MUI Drawer with anchor="bottom", enhanced with gesture handling from @use-gesture/react. The core behaviors: a drag handle at the top, swipe-to-dismiss with velocity detection, overdrag dampening, backdrop opacity sync, and safe area insets for notch padding.
Dismiss thresholds
A sheet dismisses when either condition is met: the user drags past 25% of the sheet height (deliberate drag), or the drag velocity exceeds 0.4 px/ms (quick flick regardless of distance). The velocity threshold is critical — without it, a fast downward flick that only travels 50px would snap the sheet back, which feels wrong on mobile.
The rubber band effect
When a user drags a sheet upward past its maximum height, we apply logarithmic dampening:
function dampenValue(offset: number): number {
return Math.sign(offset) * Math.log1p(Math.abs(offset)) * 10;
}
This produces the iOS-native “rubber band” feel — the sheet follows your finger with increasing resistance, then snaps back when released. Without this dampening, the sheet would just stop dead at its max height, which feels jarring.
Animation curves
The enter and exit animations use iOS spring curves. The enter is slower than the exit (420ms vs 320ms). This is intentional — content appearing deserves anticipation, but content disappearing should feel snappy. A symmetric 420ms/420ms feels sluggish; the asymmetry is what makes it feel native.
Transparent nesting
Each BottomSheet automatically registers itself with a global stack via useBottomSheetNesting(). When a new sheet opens, the sheet below it scales down by ~3% and shifts up 16px. No explicit nesting props needed — you just wrap everything in a BottomSheetNestingProvider.
<BottomSheetNestingProvider>
<BottomSheet modalState={shiftDetails}>
{/* Shift details content */}
</BottomSheet>
<BottomSheet modalState={workerProfile}>
{/* Auto-scales down shiftDetails */}
</BottomSheet>
<BottomSheet modalState={chat}>
{/* Auto-scales down workerProfile */}
</BottomSheet>
</BottomSheetNestingProvider>
Real-time interpolation during drag
When the user drags the top sheet, the sheet below interpolates in real-time. As the top sheet is dragged down, the sheet below gradually scales back up toward its normal size. The nesting provider exposes notifyDrag(percentageDragged) which the top sheet calls during gesture handling:
const scale = lerp(scaledDown, 1.0, percentageDragged);
const translateY = lerp(-16, 0, percentageDragged);
applyNestingTransform(scale, translateY, false);
The available workers panel demonstrates the deepest nesting in practice: worker list → worker profile → booked shifts, chat, or documents. That’s five potential sheets stacked on a 375px-wide screen. A closeAllSubPanels() callback ensures the entire stack collapses cleanly when the primary sheet closes.
Desktop vs mobile: same logic, different presentation
On desktop, the same six panels render as SidePanel components inside a SidePanelGroup. Panels expand leftward with 380ms cubic-bezier animations. On mobile, they render as nested bottom sheets. The container component selects the presentation based on useIsMobile(), but the state management and business logic are shared.
This is the key architectural insight: the what (shift details, chat, profile) is shared; the how (side panel vs bottom sheet) is selected at the boundary.