Skip to content

Enhance mobile responsiveness in Heading component#183

Merged
yamcodes merged 2 commits into
mainfrom
use-is-mobile
Sep 21, 2025
Merged

Enhance mobile responsiveness in Heading component#183
yamcodes merged 2 commits into
mainfrom
use-is-mobile

Conversation

@yamcodes

@yamcodes yamcodes commented Sep 20, 2025

Copy link
Copy Markdown
Owner
  • Integrated useIsMobile hook to conditionally manage active state and click events based on device type.
  • Updated state management to ensure active heading is only set on mobile devices.
  • Adjusted anchor visibility to show only on mobile, improving user experience across different screen sizes.

This change ensures that the Heading component behaves appropriately on mobile devices, enhancing usability and performance.

Summary by CodeRabbit

  • New Features

    • Headings now behave differently on mobile: active state, anchor visibility, and tap interactions apply only on mobile; desktop keeps hover/focus behavior.
    • Added viewport-aware detection so UI updates when screen size changes.
    • Improved accessibility for heading anchors (screen-reader and keyboard considerations).
  • Bug Fixes

    • Click-outside handling restricted to mobile to avoid unintended state changes on desktop.

- Integrated `useIsMobile` hook to conditionally manage active state and click events based on device type.
- Updated state management to ensure active heading is only set on mobile devices.
- Adjusted anchor visibility to show only on mobile, improving user experience across different screen sizes.

This change ensures that the Heading component behaves appropriately on mobile devices, enhancing usability and performance.
@changeset-bot

changeset-bot Bot commented Sep 20, 2025

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: dc806b7

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel

vercel Bot commented Sep 20, 2025

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
arkenv Ready Ready Preview Comment Sep 21, 2025 11:26am

@coderabbitai

coderabbitai Bot commented Sep 20, 2025

Copy link
Copy Markdown
Contributor

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Adds a new useIsMobile hook and updates the Heading component to gate active-state, anchor interactivity, and click-outside handling to mobile viewports; desktop retains navigation/scroll and hover/focus anchor behavior. Effects and handlers now depend on isMobile.

Changes

Cohort / File(s) Summary
Mobile-gated Heading behavior
apps/www/components/ui/heading.tsx
Integrates useIsMobile; conditions active-state updates, anchor click handling, and click-outside logic to run only on mobile; updates effect dependencies, accessibility attributes, and anchor rendering to be mobile-aware while preserving desktop scroll/navigation behavior.
Viewport detection hook
apps/www/hooks/use-is-mobile.ts
Adds useIsMobile hook using a (max-width: 767px) media query and window width to expose a boolean mobile flag; registers and cleans up a listener on mount/unmount.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant Heading
  participant useIsMobile
  participant DOM_Router

  Note over Heading,useIsMobile: Component mount
  Heading->>useIsMobile: query viewport (<=767px)
  useIsMobile-->>Heading: isMobile true/false

  alt isMobile = true
    User->>Heading: tap heading
    Heading->>Heading: set isActive (mobile)
    User->>Heading: tap anchor
    Heading->>Heading: set isActive (mobile)
    Heading->>DOM_Router: scroll/update hash
    User->>Heading: tap outside
    Heading->>Heading: clear isActive
  else isMobile = false
    User->>Heading: click heading
    Heading-->>User: no active-state change (hover/focus only)
    User->>Heading: click anchor
    Heading->>DOM_Router: scroll/update hash (no mobile active-state)
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

Little paws tap screens so small,
I hop to check the mobile call.
Anchors shine on hover, scrolls run through,
But only phones get sticky states anew.
🥕🐇

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title succinctly summarizes the primary change—improving mobile responsiveness for the Heading component—and aligns directly with the PR objectives and file diffs that add useIsMobile and mobile-specific behavior. It is clear and specific enough for a reviewer scanning history to understand the main intent.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch use-is-mobile

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added the www Improvements or additions to arkenv.js.org label Sep 20, 2025
cursor[bot]

This comment was marked as outdated.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/www/components/ui/heading.tsx (1)

111-121: A11y: don’t focus hidden anchors on mobile; improve label.

When hidden on mobile, the anchor remains in the tab order. Make it unfocusable and hidden to ATs unless active. Also, include the section id in the label.

 			<a
 				href={`#${id}`}
 				className={`select-none text-primary no-underline absolute -left-5 transition-opacity duration-200 ${
 					isMobile && isActive
 						? "opacity-100"
 						: "opacity-0 group-hover:opacity-100 hover:opacity-100 focus:opacity-100"
 				}`}
-				aria-label="Link to section"
-				tabIndex={0}
+				aria-label={`Link to section: ${id}`}
+				// Keep keyboard access on desktop; hide when inactive on mobile
+				tabIndex={isMobile && !isActive ? -1 : 0}
+				aria-hidden={isMobile && !isActive}
 				onClick={handleAnchorClick}
 			>
🧹 Nitpick comments (6)
apps/www/hooks/use-is-mobile.ts (4)

16-24: SSR‑safety, Safari fallback, and use mql.matches instead of window.innerWidth.

Avoid layout reads, support older Safari, and prevent undefined window during SSR by relying on MediaQueryList.matches and feature‑detecting the listener API.

-	React.useEffect(() => {
-		const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
-		const onChange = () => {
-			setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
-		};
-		mql.addEventListener("change", onChange);
-		setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
-		return () => mql.removeEventListener("change", onChange);
-	}, []);
+	React.useEffect(() => {
+		if (typeof window === "undefined" || !("matchMedia" in window)) {
+			setIsMobile(false);
+			return;
+		}
+		const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
+		const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
+		setIsMobile(mql.matches);
+		if ("addEventListener" in mql) {
+			mql.addEventListener("change", onChange);
+			return () => mql.removeEventListener("change", onChange);
+		}
+		// Safari < 14 fallback
+		// @ts-expect-error - older Safari uses addListener/removeListener
+		mql.addListener(onChange);
+		// @ts-expect-error
+		return () => mql.removeListener(onChange);
+	}, []);

11-27: Avoid N media‑query listeners across N headings.

This hook will be used by every Heading instance, creating many identical MediaQueryList subscriptions. Consider a module‑level singleton or a context/useSyncExternalStore so all consumers share one subscription.

Would you like a follow‑up patch wiring a shared store so only one matchMedia listener exists app‑wide?


3-3: Keep breakpoint in sync with design tokens.

If Tailwind’s md breakpoint changes, this constant can drift. Consider importing from a shared config or referencing CSS via matchMedia on an exported token.


26-26: Nit: prefer explicit nullish coalescing.

return isMobile ?? false reads clearer than !!isMobile.

apps/www/components/ui/heading.tsx (2)

30-66: Only subscribe on mobile to cut redundant listeners; reset state on desktop.

Currently every heading adds a document click listener and subscribes to active changes even on desktop. Early‑return when not mobile to reduce event overhead and avoid stale active state.

-	useEffect(() => {
-		const handleActiveChange = (activeId: string | null) => {
-			// Only set active state on mobile devices
-			if (isMobile) {
-				setIsActive(activeId === id);
-			}
-		};
-
-		headingListeners.add(handleActiveChange);
-
-		// Set initial state only on mobile
-		if (isMobile) {
-			setIsActive(activeHeadingId === id);
-		}
-
-		const handleClickOutside = (event: MouseEvent) => {
-			// Only handle click outside on mobile devices
-			if (!isMobile) return;
-
-			// Check if the click is outside any heading or anchor
-			const target = event.target as Element;
-			if (
-				!target.closest("h1, h2, h3, h4, h5, h6") &&
-				!target.closest('a[href^="#"]')
-			) {
-				setActiveHeading(null);
-			}
-		};
-
-		document.addEventListener("click", handleClickOutside);
-
-		return () => {
-			headingListeners.delete(handleActiveChange);
-			document.removeEventListener("click", handleClickOutside);
-		};
-	}, [id, isMobile]);
+	useEffect(() => {
+		if (!isMobile) {
+			setIsActive(false);
+			return;
+		}
+
+		const handleActiveChange = (activeId: string | null) => {
+			setIsActive(activeId === id);
+		};
+		headingListeners.add(handleActiveChange);
+
+		// initial state on mobile
+		setIsActive(activeHeadingId === id);
+
+		const handleClickOutside = (event: MouseEvent) => {
+			const target = event.target as Element;
+			if (
+				!target.closest("h1, h2, h3, h4, h5, h6") &&
+				!target.closest('a[href^="#"]')
+			) {
+				setActiveHeading(null);
+			}
+		};
+		document.addEventListener("click", handleClickOutside);
+
+		return () => {
+			headingListeners.delete(handleActiveChange);
+			document.removeEventListener("click", handleClickOutside);
+		};
+	}, [id, isMobile]);

67-83: Preserve native anchor behaviors (new tab, copy link); avoid hash jump jitter.

Allow modifier/middle‑click to pass through; use history.replaceState to avoid the second jump caused by setting location.hash after smooth scroll.

-	const handleAnchorClick = (e: React.MouseEvent) => {
-		e.preventDefault();
+	const handleAnchorClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
+		// Allow new‑tab/copy‑link, etc.
+		if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1) {
+			return;
+		}
+		e.preventDefault();
 
 		// Only set active state on mobile devices
 		if (isMobile) {
 			setActiveHeading(id ?? null);
 		}
 
 		// Always scroll to the element, even if hash is already set
-		const element = document.getElementById(id || "");
+		const element = document.getElementById(id || "");
 		if (element) {
 			element.scrollIntoView({ behavior: "smooth", block: "start" });
 		}
 
-		// Update the hash after scrolling
-		window.location.hash = `#${id}`;
+		// Update the hash without triggering a native jump
+		if (id) history.replaceState(null, "", `#${id}`);
 	};
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f36983d and 3d06414.

📒 Files selected for processing (2)
  • apps/www/components/ui/heading.tsx (5 hunks)
  • apps/www/hooks/use-is-mobile.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/www/components/ui/heading.tsx (2)
apps/www/hooks/use-is-mobile.ts (1)
  • useIsMobile (11-27)
apps/www/app/docs/[[...slug]]/page.tsx (2)
  • props (51-51)
  • props (47-47)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Cursor Bugbot
  • GitHub Check: test-build
🔇 Additional comments (1)
apps/www/components/ui/heading.tsx (1)

27-29: Nice: mobile‑only active state gating.

Gates state churn on desktop while retaining smooth scroll/navigation.

Please sanity‑check desktop: hover/focus should still reveal anchors and keyboard users should be able to tab to them.

…ssibility

- Modified the aria-label to include the section ID for better context.
- Adjusted tabIndex to maintain keyboard accessibility on desktop while hiding inactive links on mobile.
- Added visually hidden text for screen readers to enhance usability.

These changes enhance the accessibility of the Heading component, ensuring a better experience for all users.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (3)
apps/www/components/ui/heading.tsx (3)

46-48: Optional: Clear on click‑outside for desktop too (symmetry).
If active is updated on desktop (e.g., via anchor click/hashchange), consider clearing it consistently.

Apply this diff if you want consistent clearing:

-      // Only handle click outside on mobile devices
-      if (!isMobile) return;
+      // Optionally handle on all devices for consistency
+      // if (!isMobile) return;

65-65: Avoid re‑registering listeners on viewport changes.
The effect doesn’t need to depend on isMobile. If you still reference isMobile inside handlers, read it from a ref.

Apply this diff:

-  }, [id, isMobile]);
+  }, [id]);

And add this once near the top of the component:

// keep latest isMobile in handlers without re-subscribing
const isMobileRef = useRef(isMobile);
useEffect(() => {
  isMobileRef.current = isMobile;
}, [isMobile]);

Then, update the guard in the click‑outside handler:

-      if (!isMobile) return;
+      if (!isMobileRef.current) return;

118-121: Avoid duplicate accessible names; prefer one source.
aria-label already defines the accessible name, so the sr-only text is redundant. Also, don’t force tabIndex=0 on desktop; default focusability is enough.

Apply this diff:

-        aria-label={`Link to section: ${id}`}
-        // Keep keyboard access on desktop; hide when inactive on mobile
-        tabIndex={isMobile && !isActive ? -1 : 0}
+        aria-label={`Link to section: ${id}`}
+        // Keep keyboard access on desktop; hide when inactive on mobile
+        tabIndex={isMobile && !isActive ? -1 : undefined}
@@
-        <span className="sr-only">Link to section: {id}</span>
+        {/* Accessible name provided via aria-label */}
         <span aria-hidden="true">#</span>

Also applies to: 123-124

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3d06414 and dc806b7.

📒 Files selected for processing (1)
  • apps/www/components/ui/heading.tsx (5 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/www/components/ui/heading.tsx (1)
apps/www/hooks/use-is-mobile.ts (1)
  • useIsMobile (11-27)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Cursor Bugbot
🔇 Additional comments (7)
apps/www/components/ui/heading.tsx (7)

5-5: Import of useIsMobile is appropriate.
No issues with the new dependency.


28-28: Hook usage is fine.
Local boolean is all you need here.


91-94: Desktop heading click behavior — confirm intent.
Not updating active on desktop clicks is fine if only anchors should drive selection there. Verify that’s the desired UX.


114-114: Anchor visibility logic looks good.
Mobile uses active; desktop keeps hover/focus.


32-35: Don’t gate active-state updates to mobile; this recreates the URL/UI mismatch.
Active heading should sync regardless of device; visibility can still be mobile-only. This also addresses the prior “Inconsistent Active State” comment.

Apply this diff:

-      // Only set active state on mobile devices
-      if (isMobile) {
-        setIsActive(activeId === id);
-      }
+      setIsActive(activeId === id);

Additionally, wire the hash to the global active state so deep links and history nav stay in sync:

// Add once inside the component
useEffect(() => {
  const syncFromHash = () => {
    const hash = window.location.hash.slice(1);
    setActiveHeading(hash || null);
  };
  syncFromHash();
  window.addEventListener("hashchange", syncFromHash);
  return () => window.removeEventListener("hashchange", syncFromHash);
}, []);

70-73: Always update global active on anchor click.
Keeps global state aligned with the URL on desktop too; no visual change since desktop visibility is hover‑based.

Apply this diff:

-    // Only set active state on mobile devices
-    if (isMobile) {
-      setActiveHeading(id ?? null);
-    }
+    setActiveHeading(id ?? null);

40-43: Initialize from global state on all devices.
Gating the initial sync to mobile leaves stale state after resize and ignores deep links on desktop.

Apply this diff:

-    // Set initial state only on mobile
-    if (isMobile) {
-      setIsActive(activeHeadingId === id);
-    }
+    setIsActive(activeHeadingId === id);

@yamcodes
yamcodes merged commit f6a6f8c into main Sep 21, 2025
9 checks passed
@yamcodes
yamcodes deleted the use-is-mobile branch September 21, 2025 12:27
@coderabbitai coderabbitai Bot mentioned this pull request Nov 4, 2025
@coderabbitai coderabbitai Bot mentioned this pull request Jan 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

www Improvements or additions to arkenv.js.org

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant