Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 29 additions & 11 deletions apps/www/components/ui/heading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import type { ComponentProps } from "react";
import { useEffect, useState } from "react";
import { useIsMobile } from "../../hooks/use-is-mobile";

// Global state to track the active heading ID
let activeHeadingId: string | null = null;
Expand All @@ -24,18 +25,27 @@ export function Heading({
as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
}) {
const [isActive, setIsActive] = useState(false);
const isMobile = useIsMobile();

useEffect(() => {
const handleActiveChange = (activeId: string | null) => {
setIsActive(activeId === id);
// Only set active state on mobile devices
if (isMobile) {
setIsActive(activeId === id);
}
};

headingListeners.add(handleActiveChange);

// Set initial state
setIsActive(activeHeadingId === id);
// 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 (
Expand All @@ -52,11 +62,15 @@ export function Heading({
headingListeners.delete(handleActiveChange);
document.removeEventListener("click", handleClickOutside);
};
}, [id]);
}, [id, isMobile]);

const handleAnchorClick = (e: React.MouseEvent) => {
e.preventDefault();
setActiveHeading(id ?? null);

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Inconsistent Active State Across Mobile and Desktop

The active heading state is managed inconsistently: the URL hash updates unconditionally, but the internal active state and its clearing logic are mobile-only. This creates a mismatch between the URL hash and the UI's active heading, especially when switching between mobile and desktop. Additionally, isMobile in the useEffect dependencies causes unnecessary re-initialization of listeners and can incorrectly activate headings on viewport changes.

Fix in Cursor Fix in Web


// Always scroll to the element, even if hash is already set
const element = document.getElementById(id || "");
Expand All @@ -74,8 +88,10 @@ export function Heading({
return;
}

// Show the anchor but don't scroll - only the pound symbol should scroll
setActiveHeading(id ?? null);
// Only show the anchor on mobile - on desktop it will only show on hover
if (isMobile) {
setActiveHeading(id ?? null);
}
};

if (!id)
Expand All @@ -95,15 +111,17 @@ export function Heading({
<a
href={`#${id}`}
className={`select-none text-primary no-underline absolute -left-5 transition-opacity duration-200 ${
isActive
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}
onClick={handleAnchorClick}
>
#
<span className="sr-only">Link to section: {id}</span>
<span aria-hidden="true">#</span>
</a>
{children}
</Component>
Expand Down
27 changes: 27 additions & 0 deletions apps/www/hooks/use-is-mobile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as React from "react";

const MOBILE_BREAKPOINT = 768;

/**
* Hook to detect if the current viewport is mobile-sized.
*
* Based on the implementation from Vercel AI Chatbot:
* @see https://github.com/vercel/ai-chatbot/blob/main/hooks/use-mobile.tsx
*/
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);

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);
}, []);

return !!isMobile;
}
Loading