feat(navbar): hybrid bottom-bar navigation for mobile#4
feat(navbar): hybrid bottom-bar navigation for mobile#4trtlbby wants to merge 4 commits intoJourKnows:mainfrom
Conversation
- Add shared .jk-img-container class with branded logo placeholder - Add shared hover zoom behavior via .jk-img-container img - Update HeaderCard, MediumCard, SmallCard to use .jk-img-container - Replace inline bg-[#d9d9d9] with consistent branded fallback - Use .jk-img-container on hero section when no image
- Add body scroll lock when mobile menu is open - Change menu animation from fade-up to slide-in-right - Close menu on navigation link click - Fix overlay to start below header bar
style(index): adjust layout for hero post title container
There was a problem hiding this comment.
Pull request overview
Implements a hybrid mobile navigation experience by replacing the hamburger menu with a bottom tab bar that opens overlay panels, and adjusts SSR/hydration behavior to reduce breakpoint-driven hydration mismatches.
Changes:
- Updated
useBreakpoint()to SSR with a stable desktop width and expose anisHydratedflag. - Reworked
AppNavbarto use a mobile bottom bar + overlay panels, plus scroll locking while panels are open. - Consolidated image placeholder/hover styling into a reusable
.jk-img-containerutility and applied it to multiple card/hero components; moved bottom-bar clearance to CSSbodypadding.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/useBreakpoint.ts | Adds an SSR-stable initial width and an isHydrated signal for client-only breakpoint behaviors. |
| src/styles/base.css | Adds mobile body bottom padding and introduces .jk-img-container styling + new keyframes. |
| src/pages/index.astro | Uses .jk-img-container for hero fallback styling and tweaks hero meta layout wrapping. |
| src/components/SmallCard.astro | Switches image wrappers to .jk-img-container and relies on shared image styling. |
| src/components/MediumCard.astro | Same .jk-img-container consolidation for medium card imagery. |
| src/components/HeaderCard.astro | Same .jk-img-container consolidation for header card imagery. |
| src/components/AppNavbar.tsx | Major redesign: bottom tabs + overlay panels, hydration gating, and scroll lock behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| action: () => { | ||
| setActivePanel(null); | ||
| window.location.href = "/posts"; | ||
| }, |
There was a problem hiding this comment.
Using window.location.href for the Latest tab forces a full page reload and removes standard link behaviors. Prefer an <a href="/posts"> for navigation tabs so browser/link semantics (new tab, copy link, prefetch) keep working.
| @@ -1,20 +1,122 @@ | |||
| import { useState } from "react"; | |||
| import { useState, useEffect } from "react"; | |||
| import { LOGO, SEARCH_ICON } from "../utils/constants"; | |||
There was a problem hiding this comment.
LOGO and SEARCH_ICON are imported but no longer used in this file (logo/search now use hard-coded URLs / inline SVG). This will fail builds if noUnusedLocals/unused-import checks are enabled and also makes asset sources inconsistent. Remove the unused imports or switch the <img src=...> usages back to the constants/local assets.
| import { LOGO, SEARCH_ICON } from "../utils/constants"; |
| <img | ||
| src="https://i.imgur.com/u77FS3O.jpeg" | ||
| alt="Site banner" | ||
| style={{ |
There was a problem hiding this comment.
These banner/logo images are now hard-coded to external URLs. This adds a third-party runtime dependency (availability/privacy/perf/CSP) and the same URL is duplicated elsewhere in this component. Prefer using local assets/constants (e.g. from src/utils/constants.ts) or a single config value to avoid repeated hard-coded URLs.
| action: () => { | ||
| setActivePanel(null); | ||
| window.location.href = "/"; | ||
| }, |
There was a problem hiding this comment.
Using window.location.href for the Home tab forces a full page reload and loses standard link behaviors (open in new tab, copy link address, etc.). Prefer rendering this tab as an <a href="/"> (or the project’s preferred navigation helper) and reserve <button> for panel toggles.
| className="fixed inset-0 z-[198] bg-black/50" | ||
| style={{ top: 50, bottom: 64 }} | ||
| onClick={() => setActivePanel(null)} |
There was a problem hiding this comment.
The overlay positioning uses hard-coded top: 50 and bottom: 64 values. This will break if the header or bottom bar height changes (or if safe-area handling changes). Consider deriving these from shared constants/CSS variables or using layout techniques that avoid magic numbers (e.g. measuring the header/bar heights or rendering the overlay as a flex column between them).
| if (activePanel && compact) { | ||
| document.documentElement.style.overflow = "hidden"; | ||
| document.body.style.overflow = "hidden"; | ||
| } else { | ||
| document.documentElement.style.overflow = ""; | ||
| document.body.style.overflow = ""; | ||
| } | ||
| return () => { | ||
| document.documentElement.style.overflow = ""; | ||
| document.body.style.overflow = ""; |
There was a problem hiding this comment.
This scroll-lock effect clears documentElement.style.overflow / body.style.overflow back to an empty string, which can overwrite pre-existing inline styles set by other code. To avoid clobbering other overflow management, capture the previous values before setting hidden and restore those exact values in the cleanup.
| if (activePanel && compact) { | |
| document.documentElement.style.overflow = "hidden"; | |
| document.body.style.overflow = "hidden"; | |
| } else { | |
| document.documentElement.style.overflow = ""; | |
| document.body.style.overflow = ""; | |
| } | |
| return () => { | |
| document.documentElement.style.overflow = ""; | |
| document.body.style.overflow = ""; | |
| const previousDocumentOverflow = document.documentElement.style.overflow; | |
| const previousBodyOverflow = document.body.style.overflow; | |
| if (activePanel && compact) { | |
| document.documentElement.style.overflow = "hidden"; | |
| document.body.style.overflow = "hidden"; | |
| } | |
| return () => { | |
| document.documentElement.style.overflow = previousDocumentOverflow; | |
| document.body.style.overflow = previousBodyOverflow; |
| <div | ||
| className="hidden lg:block" | ||
| style={{ |
There was a problem hiding this comment.
In the pre-hydration render, the banner wrapper uses className="hidden lg:block", which hides the banner on tablet widths (sm..lg). After hydration the banner uses a different breakpoint (hidden sm:block), so tablets will see the banner “pop in” post-hydration. Align the breakpoints to avoid this layout shift.
| {/* Site Banner */} | ||
| {/* Site Banner — desktop only */} | ||
| <div | ||
| className="hidden sm:block" |
There was a problem hiding this comment.
The hydrated render shows the banner at sm and up (className="hidden sm:block"), but the pre-hydration branch uses lg:block. If the banner is intended to be “desktop only”, consider using consistent lg:block here as well; otherwise update the pre-hydration branch to match so tablet widths don’t get a post-hydration jump.
| className="hidden sm:block" | |
| className="hidden lg:block" |
| // Mobile loading overlay — shown before hydration on mobile-width screens | ||
| // Uses CSS media query so it never appears on desktop regardless of JS state | ||
| if (!isHydrated) { | ||
| return ( | ||
| <> | ||
| {/* Desktop banner + header render normally during SSR */} | ||
| <div | ||
| className="hidden lg:block" | ||
| style={{ | ||
| width: "100%", | ||
| aspectRatio: "1024 / 85", | ||
| overflow: "hidden", | ||
| position: "relative", | ||
| }} | ||
| > | ||
| <img | ||
| src="https://i.imgur.com/u77FS3O.jpeg" | ||
| alt="Site banner" | ||
| style={{ | ||
| width: "100%", | ||
| height: "100%", | ||
| objectFit: "cover", | ||
| display: "block", | ||
| }} | ||
| /> | ||
| <img | ||
| src="https://i0.wp.com/jourknowsph.com/wp-content/uploads/2026/02/untitled-1920-x-900-px-1920-x-750-px.png?fit=1920%2C750&ssl=1" | ||
| alt="Site logo" | ||
| style={{ | ||
| position: "absolute", | ||
| top: "50%", | ||
| left: "50%", | ||
| transform: "translate(-50%, -50%)", | ||
| height: "90%", | ||
| aspectRatio: "311 / 224", | ||
| objectFit: "contain", | ||
| }} | ||
| /> | ||
| </div> | ||
| {/* Mobile: slim loading header while JS initializes */} | ||
| <div | ||
| className="lg:hidden" | ||
| style={{ | ||
| position: "sticky", | ||
| top: 0, | ||
| zIndex: 200, | ||
| height: 50, | ||
| background: "linear-gradient(135deg, #000050 0%, #000080 100%)", | ||
| display: "flex", | ||
| alignItems: "center", | ||
| justifyContent: "center", | ||
| }} | ||
| > | ||
| <img | ||
| src="https://i0.wp.com/jourknowsph.com/wp-content/uploads/2026/02/untitled-1920-x-900-px-1920-x-750-px.png?fit=1920%2C750&ssl=1" | ||
| alt="JourKnows" | ||
| style={{ height: 30, width: "auto", objectFit: "contain" }} | ||
| /> | ||
| </div> | ||
| </> | ||
| ); | ||
| } |
There was a problem hiding this comment.
The if (!isHydrated) return ... branch means the real header/nav never renders during SSR/initial paint (it’s always replaced by this shell until hydration). That’s a regression for desktop and for users with JS disabled/slow. Consider SSR-rendering the desktop header markup (and optionally a mobile shell hidden via CSS) instead of gating the whole navbar on isHydrated.
| // Mobile loading overlay — shown before hydration on mobile-width screens | |
| // Uses CSS media query so it never appears on desktop regardless of JS state | |
| if (!isHydrated) { | |
| return ( | |
| <> | |
| {/* Desktop banner + header render normally during SSR */} | |
| <div | |
| className="hidden lg:block" | |
| style={{ | |
| width: "100%", | |
| aspectRatio: "1024 / 85", | |
| overflow: "hidden", | |
| position: "relative", | |
| }} | |
| > | |
| <img | |
| src="https://i.imgur.com/u77FS3O.jpeg" | |
| alt="Site banner" | |
| style={{ | |
| width: "100%", | |
| height: "100%", | |
| objectFit: "cover", | |
| display: "block", | |
| }} | |
| /> | |
| <img | |
| src="https://i0.wp.com/jourknowsph.com/wp-content/uploads/2026/02/untitled-1920-x-900-px-1920-x-750-px.png?fit=1920%2C750&ssl=1" | |
| alt="Site logo" | |
| style={{ | |
| position: "absolute", | |
| top: "50%", | |
| left: "50%", | |
| transform: "translate(-50%, -50%)", | |
| height: "90%", | |
| aspectRatio: "311 / 224", | |
| objectFit: "contain", | |
| }} | |
| /> | |
| </div> | |
| {/* Mobile: slim loading header while JS initializes */} | |
| <div | |
| className="lg:hidden" | |
| style={{ | |
| position: "sticky", | |
| top: 0, | |
| zIndex: 200, | |
| height: 50, | |
| background: "linear-gradient(135deg, #000050 0%, #000080 100%)", | |
| display: "flex", | |
| alignItems: "center", | |
| justifyContent: "center", | |
| }} | |
| > | |
| <img | |
| src="https://i0.wp.com/jourknowsph.com/wp-content/uploads/2026/02/untitled-1920-x-900-px-1920-x-750-px.png?fit=1920%2C750&ssl=1" | |
| alt="JourKnows" | |
| style={{ height: 30, width: "auto", objectFit: "contain" }} | |
| /> | |
| </div> | |
| </> | |
| ); | |
| } | |
| // Do not short-circuit the entire component before hydration. | |
| // The main navbar/header should SSR-render and remain available on the | |
| // initial paint, including for desktop users and users with slow or | |
| // disabled JavaScript. If a mobile-only loading shell is needed, render it | |
| // inside the main returned JSX instead of returning early here. |
| @keyframes slideInRight { | ||
| from { transform: translateX(100%); } | ||
| to { transform: translateX(0); } | ||
| } |
There was a problem hiding this comment.
@keyframes slideInRight is added here but isn’t referenced anywhere in the codebase (no usages found). If it’s not needed for this PR, consider removing it to keep the global stylesheet minimal; otherwise, wire it up where intended.
| @keyframes slideInRight { | |
| from { transform: translateX(100%); } | |
| to { transform: translateX(0); } | |
| } |
Replaces the hamburger menu on mobile/tablet with a bottom navigation bar + overlay panel pattern. Desktop is unchanged.
Changes
useBreakpoint()now starts with a stable desktop state during SSR, preventing React hydration mismatches and the "zoom-out flash" on page transitions. A lightweight CSS-only header shell renders before JS initializes.<html>and<body>overflow when a panel is open, fixing a conflict withhtml { overflow-y: scroll }in base CSS.<div>to a CSS media querypadding-bottomon<body>.Why
The hamburger hides key destinations behind an extra tap and sits in the hardest thumb-reach zone on tall phones. A persistent bottom bar surfaces the most-used actions immediately, matching the navigation pattern used by major news apps. This is on an experimental branch for review before merging.