diff options
| author | Nikhil M Warrier <nikhilmw.dev@gmail.com> | 2026-03-03 09:06:04 +0530 |
|---|---|---|
| committer | Nikhil M Warrier <nikhilmw.dev@gmail.com> | 2026-03-03 09:06:04 +0530 |
| commit | 2cfd3cb55f2a13403eac429111320ec07c8c796b (patch) | |
| tree | ad0e0e8f9f8aa66529919d966868eaf22b5cd80e /src/content/blog/mobile-nav-and-subposts/mobile-navigation.mdx | |
| parent | 814dfcab593545ca1eca70647b6739bbb59030ee (diff) | |
Remove some placeholder content
Diffstat (limited to 'src/content/blog/mobile-nav-and-subposts/mobile-navigation.mdx')
| -rw-r--r-- | src/content/blog/mobile-nav-and-subposts/mobile-navigation.mdx | 152 |
1 files changed, 0 insertions, 152 deletions
diff --git a/src/content/blog/mobile-nav-and-subposts/mobile-navigation.mdx b/src/content/blog/mobile-nav-and-subposts/mobile-navigation.mdx deleted file mode 100644 index aa1b4f1..0000000 --- a/src/content/blog/mobile-nav-and-subposts/mobile-navigation.mdx +++ /dev/null @@ -1,152 +0,0 @@ ---- -title: 'Implementing sticky mobile navigation' -description: 'Technical deep-dive into the sticky mobile table of contents with progress tracking and smart section highlighting' -date: 2025-05-21 -tags: ['v1.6.0'] -authors: ['enscribe'] -order: 1 ---- - -import Callout from '@/components/Callout.astro' - -The original mobile table of contents was a simple collapsible element that lived within the post content. This created several usability issues: -1. Users had no sense of how much content remained other than implying it based on the length of the browser scrollbar -2. Once you scrolled past the TOC, you lose the ability to quickly navigate to other sections of the post without scrolling back up to the inline TOC -3. Mobile users should have the exact same experience as desktop users in terms of navigation, and as of now the desktop experience was better (due to the sticky aside TOC) - -## Building the sticky header system - -Design-wise, the component is relatively simple, since it only includes a chevron to indicate expansion state, a circular progress indicator that fills as you scroll, and a dynamic text showing the current section (or a combination of sections, if multiple are visible at the same time). - -### Live scroll highlighting sucks - -One of the more interesting problems I encountered was how to handle the highlighting of sections as you scroll. Of course, this applies to both mobile and desktop versions, but in this update I changed the implementation of both. - -A naive implementation of live scroll highlighting would simply use an `IntersectionObserver(){:js}` to watch for headers entering and exiting the viewport. The issue with this is that it doesn't highlight anything if headers are no longer visible in your viewport, regardless of whether you're in a section that "belongs" to a heading. - -<Callout variant="example"> - Say that we have a post with the following structure: - - ```markdown - ## Part 1 - [500 lines of content] - - ## Part 2 - [500 lines of content] - ``` - - If you were to scroll way past the first heading and was deep into the first section underneath it, the naive implementation would not highlight the first heading because it's no longer in your viewport. This is unintuitive and a poor user experience. In a perfect world, if we were to view 250 lines of Part 1 and 250 lines of Part 2, then we would see both headings highlighted in the TOC and not need to make a decision about which heading to highlight. -</Callout> - -I used to rely on [jakelow/remark-sectionize](https://github.com/jake-low/remark-sectionize), a [remarkjs/remark](https://github.com/remarkjs/remark) plugin that retroactively generates `<section>{:html}` tags based on the headers in the generated HTML. This would have done the following conversion: - -<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 [&_img,p,.expressive-code]:m-0"> - <div> - -```markdown -# Forest elephants - -## Introduction - -In this section, we discuss the lesser known forest elephants. - -## Habitat - -Forest elephants do not live in trees but among them. -``` - - </div> - <div> - -```html -<section> - <h1>Forest elephants</h1> - <section> - <h2>Introduction</h2> - <p>In this section, we discuss the lesser known forest elephants.</p> - </section> - <section> - <h2>Habitat</h2> - <p>Forest elephants do not live in trees but among them.</p> - </section> -</section> -``` - - </div> -</div> - -However, this approach had pretty complicated issues involving section nesting and the fact that we didn't have any control over its output other than by patching it. So, I decided to opt for a home-grown solution. - -### The concept of "jurisdictions" - -The naming is interesting but I felt like it was the most intuitive to me. Basically, I created a system that assigns each heading a "territory" that extends from its position to the start of the next heading (or the end of the document): - -```javascript title="src/components/TOCHeader.astro" startLineNumber={123} -function buildHeadingJurisdictions() { - headingElements = Array.from( - document.querySelectorAll('.prose h2, .prose h3, .prose h4, .prose h5, .prose h6') - ) - - jurisdictions = headingElements.map((heading, index) => { - const nextHeading = headingElements[index + 1] - return { - id: heading.id, - start: heading.offsetTop, - end: nextHeading ? nextHeading.offsetTop : document.body.scrollHeight - } - }) -} -``` - -1. First, we collect all heading elements (`<h2>{:html}` through `<h6>{:html}`) from the document's prose content area using `.querySelectorAll(){:js}`. -2. For each heading, we create a jurisdiction object that contains the heading's `id`, the vertical position where this section begins (the heading's `offsetTop` value, which we name `start`), and the vertical position where this section ends (the `offsetTop` of the next heading or the bottom of the document if it's the last heading, which we name `end`). - -This creates a map of "territories" that each heading controls. This is crucial for accurately tracking which jurisdictions are currently visible as the user scrolls, even when the actual heading element itself is no longer in view. - -### The decision to show all visible sections - -One of the more interesting decisions I made was to display all sections as comma-separated within the mobile TOC's unexpanded state. This manifests as follows: - -<Callout variant="example"> - Recall the previous example's scenario: - ```markdown - ## Part 1 - [500 lines of content] - - ## Part 2 - [500 lines of content] - ``` - If we saw 250 lines of Part 1 and 250 lines of Part 2, then the text snippet in the mobile TOC would read "Part 1, Part 2". -</Callout> - -The temptation is to implement a "smart" selection algorithm, perhaps showing the section with the most visible content, or the one closest to the viewport center, or to show the "deepest," most specifically nested section. However, this creates numerous edge cases: - -1. If you click to navigate to a short final section, it might never become the "primary" section because there isn't enough content below it to scroll it to the top of the viewport. - -2. As you scroll between sections, a "smart" selector might switch which section it considers primary at seemingly arbitrary points, creating a jarring experience. - -3. When your viewport shows roughly equal amounts of two sections, any selection algorithm becomes essentially arbitrary. - -By showing all visible sections, we give users complete awareness of their position in the document, eliminate the edge cases mentioned above, and create predictable behavior. - -## Progress indicator implementation - -The circular progress indicator provides immediate visual feedback about reading progress without requiring any interaction: - -```javascript title="src/components/TOCHeader.astro" startLineNumber={216} - function updateProgressCircle() { - if (!progressCircleElement) return - const scrollableDistance = - document.documentElement.scrollHeight - window.innerHeight - const scrollProgress = - scrollableDistance > 0 - ? Math.min(Math.max(window.scrollY / scrollableDistance, 0), 1) - : 0 - progressCircleElement.style.strokeDashoffset = ( - PROGRESS_CIRCLE_CIRCUMFERENCE * - (1 - scrollProgress) - ).toString() - } -``` - -The progress is calculated as a ratio of current scroll position to total scrollable distance, then applied as a stroke-dashoffset to create the filling effect.
\ No newline at end of file |
