Restore mobile bottom nav with scroll-to-hide behavior

Bring back the mobile bottom navigation bar with Home, Notifications, and
Search buttons. The nav slides out of view when scrolling down and reappears
on upward scroll, keeping the viewport uncluttered while reading. Search
retains the popover account search sheet for fast profile lookup without
navigating away. Updated CSS height calculations for full-screen pages
(AI chat, vines, livestream) and FAB positioning to account for the nav.
This commit is contained in:
Alex Gleason
2026-03-13 22:47:17 -05:00
parent 3727975178
commit 59311e6b2c
7 changed files with 191 additions and 17 deletions
+3 -1
View File
@@ -4,6 +4,7 @@ import { LeftSidebar } from '@/components/LeftSidebar';
import { RightSidebar } from '@/components/RightSidebar';
import { MobileTopBar } from '@/components/MobileTopBar';
import { MobileDrawer } from '@/components/MobileDrawer';
import { MobileBottomNav } from '@/components/MobileBottomNav';
import { FloatingComposeButton } from '@/components/FloatingComposeButton';
import { CursorFireEffect } from '@/components/CursorFireEffect';
import { Skeleton } from '@/components/ui/skeleton';
@@ -101,7 +102,8 @@ function MainLayoutInner() {
</Suspense>
</div>
{/* Mobile bottom nav - only on small screens, slides out on scroll */}
<MobileBottomNav />
</>
);
}
+2 -2
View File
@@ -16,8 +16,8 @@ function getStoredPosition(): { x: number; y: number } | null {
}
function getBottomOffset() {
// Reserve space for mobile bottom nav (56px) below the sidebar breakpoint
const hasSidebar = window.matchMedia('(min-width: 600px)').matches;
// On mobile (below sidebar breakpoint), reserve space for the bottom nav (56px)
const hasSidebar = window.matchMedia('(min-width: 900px)').matches;
return hasSidebar ? 0 : 56;
}
+112
View File
@@ -0,0 +1,112 @@
import { useCallback, useMemo, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Bell, Search } from 'lucide-react';
import { PlanetIcon } from '@/components/icons/PlanetIcon';
import { cn } from '@/lib/utils';
import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { useScrollDirection } from '@/hooks/useScrollDirection';
import { MobileSearchSheet } from '@/components/MobileSearchSheet';
import { getSidebarItem } from '@/lib/sidebarItems';
export function MobileBottomNav() {
const location = useLocation();
const { user } = useCurrentUser();
const { config } = useAppContext();
const homePage = config.homePage;
const hasUnread = useHasUnreadNotifications();
const { hidden } = useScrollDirection();
const [searchOpen, setSearchOpen] = useState(false);
const homeItem = useMemo(() => getSidebarItem(homePage), [homePage]);
const HomeIcon = homeItem?.icon ?? PlanetIcon;
const homeLabel = homeItem?.label ?? 'Feed';
const handleHomeClick = useCallback((e: React.MouseEvent) => {
setSearchOpen(false);
if (location.pathname === '/') {
e.preventDefault();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, [location.pathname]);
const handleNotificationsClick = useCallback(() => {
setSearchOpen(false);
}, []);
const handleSearchClick = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setSearchOpen((v) => !v);
}, []);
// Don't show notifications/search in bottom nav if they are the homepage
const showNotifications = homePage !== 'notifications';
const showSearch = homePage !== 'search';
// Keep the nav visible while search is open regardless of scroll
const isHidden = hidden && !searchOpen;
return (
<>
<MobileSearchSheet open={searchOpen} onClose={() => setSearchOpen(false)} />
<nav
className={cn(
'fixed bottom-0 left-0 right-0 z-40 bg-background/80 backdrop-blur-md border-t border-border sidebar:hidden safe-area-bottom',
'transition-transform duration-300 ease-in-out',
isHidden && 'translate-y-full',
)}
>
<div className="h-14 flex items-center">
<Link
to="/"
onClick={handleHomeClick}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
location.pathname === '/' ? 'text-primary' : 'text-muted-foreground',
)}
>
<HomeIcon className="size-5" />
<span className="text-[10px] font-medium">{homeLabel}</span>
</Link>
{user && showNotifications && (
<Link
to="/notifications"
onClick={handleNotificationsClick}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
location.pathname === '/notifications' ? 'text-primary' : 'text-muted-foreground',
)}
>
<span className="relative">
<Bell className="size-5" />
{hasUnread && (
<span className="absolute -top-1 right-0 size-2 bg-primary rounded-full" />
)}
</span>
<span className="text-[10px] font-medium">Notifications</span>
</Link>
)}
{showSearch && (
<button
onClick={handleSearchClick}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
searchOpen ? 'text-primary' : 'text-muted-foreground',
)}
>
<Search className="size-5" />
<span className="text-[10px] font-medium">Search</span>
</button>
)}
</div>
</nav>
</>
);
}
+3 -4
View File
@@ -122,16 +122,15 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
return (
<>
{/* Backdrop — doesn't cover the bottom nav (z-30) */}
{/* Backdrop */}
<div
className="fixed inset-0 z-40 bg-black/60 sidebar:hidden animate-in fade-in-0 duration-150"
onClick={handleClose}
/>
{/* Bottom sheet — sits above the bottom nav bar */}
<div className="fixed left-0 right-0 z-[49] sidebar:hidden animate-in slide-in-from-bottom-4 duration-200"
style={{ bottom: 'calc(3.5rem + env(safe-area-inset-bottom, 0px))' }}
>
<div className="fixed left-0 right-0 z-[49] sidebar:hidden animate-in slide-in-from-bottom-4 duration-200 bottom-mobile-nav">
{/* Results list — reversed so closest to input = most relevant */}
{hasResults && (
<div className="flex flex-col-reverse bg-popover/95 rounded-2xl mx-6 mb-0.5 overflow-hidden max-h-[55vh] overflow-y-auto shadow-lg">
+1 -1
View File
@@ -10,7 +10,7 @@ import { useEncryptedSettings } from './useEncryptedSettings';
* Fetches at most 1 event (using `since` to filter at the relay level),
* making it much cheaper than loading the full notification list.
*
* Use this in navigation components (sidebar, bottom nav) for the dot indicator.
* Use this in navigation components (sidebar, mobile bottom nav) for the dot indicator.
* Use `useNotifications` on the actual notifications page where the full list is needed.
*/
export function useHasUnreadNotifications(): boolean {
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useRef, useState } from 'react';
/**
* Tracks the user's scroll direction and returns whether the mobile chrome
* (bottom nav) should be hidden.
*
* Rules:
* - Hidden when the user scrolls DOWN past a small threshold.
* - Revealed when the user scrolls UP by the same threshold.
* - Always revealed when the page is scrolled near the very top.
*/
export function useScrollDirection(): { hidden: boolean } {
const [hidden, setHidden] = useState(false);
const lastScrollY = useRef(0);
const accumulated = useRef(0);
useEffect(() => {
const THRESHOLD = 8; // px of continuous movement before toggling
const NEAR_TOP = 60; // px from top where chrome is always visible
const onScroll = () => {
const currentY = window.scrollY;
if (currentY <= NEAR_TOP) {
// Always show chrome near the top of the page
accumulated.current = 0;
setHidden(false);
lastScrollY.current = currentY;
return;
}
const delta = currentY - lastScrollY.current;
lastScrollY.current = currentY;
if (Math.sign(delta) !== Math.sign(accumulated.current)) {
// Direction changed — reset accumulator
accumulated.current = delta;
} else {
accumulated.current += delta;
}
if (accumulated.current > THRESHOLD) {
setHidden(true);
accumulated.current = 0;
} else if (accumulated.current < -THRESHOLD) {
setHidden(false);
accumulated.current = 0;
}
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
return { hidden };
}
+14 -9
View File
@@ -40,9 +40,14 @@
bottom: env(safe-area-inset-bottom, 0px);
}
/* FAB bottom offset: clears safe area inset */
/* FAB bottom offset: clears bottom nav + safe area inset on mobile */
.bottom-fab {
bottom: calc(1.5rem + env(safe-area-inset-bottom, 0px));
bottom: calc(1.5rem + 3.5rem + env(safe-area-inset-bottom, 0px));
}
/* Position above mobile bottom nav (h-14 = 3.5rem) + safe area */
.bottom-mobile-nav {
bottom: calc(3.5rem + env(safe-area-inset-bottom, 0px));
}
/* Mobile top bar height (48px) + safe area inset for sticky elements */
@@ -50,20 +55,20 @@
top: calc(3rem + env(safe-area-inset-top, 0px));
}
/* AI chat height on mobile: full viewport minus top bar and safe-area insets */
/* AI chat height on mobile: full viewport minus top bar, bottom nav (3.5rem), and safe-area insets */
.ai-chat-height {
height: calc(100dvh - 3rem - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
height: calc(100dvh - 3rem - 3.5rem - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
}
/* Live stream page height on mobile: full viewport minus top bar and safe-area insets */
/* Live stream page height on mobile: full viewport minus top bar, bottom nav, and safe-area insets */
.livestream-height {
height: calc(100dvh - 3rem - env(safe-area-inset-bottom, 0px));
max-height: calc(100dvh - 3rem - env(safe-area-inset-bottom, 0px));
height: calc(100dvh - 3rem - 3.5rem - env(safe-area-inset-bottom, 0px));
max-height: calc(100dvh - 3rem - 3.5rem - env(safe-area-inset-bottom, 0px));
}
/* Vine feed slide height: full viewport minus top bar, tab bar, and safe-area insets */
/* Vine feed slide height: full viewport minus top bar, tab bar, bottom nav, and safe-area insets */
.vine-slide-height {
height: calc(100dvh - env(safe-area-inset-top, 0px) - 3rem - 2.5rem - env(safe-area-inset-bottom, 0px));
height: calc(100dvh - env(safe-area-inset-top, 0px) - 3rem - 2.5rem - 3.5rem - env(safe-area-inset-bottom, 0px));
}
}