Skip to content

feat(web): add scroll-spy active indicator to TOC#657

Open
sebassg wants to merge 1 commit intonodejs:mainfrom
sebassg:feat/toc-scroll-spy
Open

feat(web): add scroll-spy active indicator to TOC#657
sebassg wants to merge 1 commit intonodejs:mainfrom
sebassg:feat/toc-scroll-spy

Conversation

@sebassg
Copy link

@sebassg sebassg commented Mar 8, 2026

Description

Use IntersectionObserver to track the currently visible heading and highlight it in the MetaBar table of contents. Passes activeSlug via Context
to a custom ActiveLink component rendered through the existing 'as' prop of MetaBar, requiring no changes to @node-core/ui-components.

Validation

  • Navigate a page with multiple headings and verify the active TOC item updates as you scroll
  • The active link should be visually highlighted in the MetaBar TOC
  • Unit tests cover the useScrollSpy hook: src/generators/web/ui/hooks/__tests__/useScrollSpy.test.mjs

Related Issues

Closes #529

Check List

  • I have read the Contributing Guidelines and made commit messages
    that follow the guideline.
  • I have run node --run test and all tests passed.
  • I have check code formatting with node --run format & node --run lint.
  • I've covered new added functionality with unit tests if necessary.

  Use IntersectionObserver to track the currently visible heading and
  highlight it in the MetaBar table of contents. Passes activeSlug via
  Context to a custom ActiveLink component rendered through the existing
  'as' prop of MetaBar, requiring no changes to @node-core/ui-components.
Copilot AI review requested due to automatic review settings March 8, 2026 04:23
@sebassg sebassg requested a review from a team as a code owner March 8, 2026 04:23
@vercel
Copy link

vercel bot commented Mar 8, 2026

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

Project Deployment Actions Updated (UTC)
api-docs-tooling Ready Ready Preview Mar 8, 2026 4:24am

Request Review

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR implements a scroll-spy feature for the Table of Contents (TOC) in the MetaBar component. It uses IntersectionObserver to track which heading is currently visible in the viewport and highlights the corresponding TOC entry with a visual indicator (bold text, primary color, and a left border).

Changes:

  • Added a useScrollSpy hook that tracks the currently visible heading via IntersectionObserver with a 30% viewport detection zone
  • Created an ActiveLink component that uses React Context to conditionally apply active styles to TOC links matching the visible heading
  • Added CSS styles and unit tests for the getActiveSlug helper function

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
src/generators/web/ui/hooks/useScrollSpy.mjs New hook using IntersectionObserver to track the active heading slug
src/generators/web/ui/hooks/__tests__/useScrollSpy.test.mjs Unit tests for the getActiveSlug pure function
src/generators/web/ui/components/MetaBar/index.module.css New .tocActive CSS class for highlighting the active TOC item
src/generators/web/ui/components/MetaBar/index.jsx Integrates scroll-spy via Context + ActiveLink component passed as as prop to upstream MetaBar

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +16 to +17
border-left: 2px solid var(--color-primary, #0078d4);
padding-left: 0.25rem;
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

The .tocActive class adds border-left: 2px solid and padding-left: 0.25rem only to the active item. When the active heading changes during scrolling, the newly active link gains this border+padding while the previously active link loses it, causing a visible horizontal layout shift. Consider adding a transparent border (e.g., border-left: 2px solid transparent) and the same padding-left to the default/non-active link state so that only the border color changes on activation.

Suggested change
border-left: 2px solid var(--color-primary, #0078d4);
padding-left: 0.25rem;
box-shadow: inset 2px 0 0 var(--color-primary, #0078d4);

Copilot uses AI. Check for mistakes.
margin-left: 0.25rem;
}

.tocActive {
Copy link
Member

Choose a reason for hiding this comment

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

that should be do in the ui-component package which is disponible on nodejs/nodejs.org repo

Copy link
Author

Choose a reason for hiding this comment

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

Yes, thanks! I’ll first make the change in the ui-component package, and then update the implementation here afterward

Copy link
Member

Choose a reason for hiding this comment

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

This is a tough one, team (cc @nodejs/nodejs-website) should this active ToC entry be implemented straight on node-core/ui-components? MetBar is customizable and not necessarily has a ToC entry, as it is simply an entry as any other on the MetaBar, although feels something that could be reused there.

Copy link
Member

Choose a reason for hiding this comment

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

And if that's the case, @sebassg it'd mean this whole PR (or good chunk of it, like the effect, styles, etc) probably should land on ui-components package, although I'm unsure in which shape or format.

Copy link
Member

Choose a reason for hiding this comment

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

On my side is was proposing to add new props to toc component

<a
href={href}
aria-current={href === `#${activeSlug}` ? 'true' : undefined}
className={[className, href === `#${activeSlug}` ? styles.tocActive : '']
Copy link
Member

Choose a reason for hiding this comment

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

nit: use the already bundled classNames package?

* Anchor element that highlights itself when it matches the active TOC slug.
* @param {{ href: string, className?: string }} props
*/
const ActiveLink = ({ href, className, ...props }) => {
Copy link
Member

Choose a reason for hiding this comment

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

Do we really need a provider? Couldn't you just make the component ActiveLink defined within the defaultExport and then having it indirectly having the ref of the activeSlug?

const activeSlug = useScrollSpy(headings);

const ActiveLink = ({ href, className, ...props }) => {
  ...
  component code
};

... remaining of MetaBar code

* @returns {string | null} The slug of the active heading
*/
export const useScrollSpy = headings => {
const [activeSlug, setActiveSlug] = useState(null);
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
const [activeSlug, setActiveSlug] = useState(null);
const [activeSlug, setActiveSlug] = useState();

entries => {
const slug = getActiveSlug(entries);

if (slug !== null) {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
if (slug !== null) {
if (typeof activeSlug === 'string') {

setActiveSlug(slug);
}
},
{ rootMargin: '0px 0px -70% 0px', threshold: 0 }
Copy link
Member

Choose a reason for hiding this comment

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

could you add some inline comments // explaining these? Like why threshold 0 and what these values are supposed to mean for rootMargin? :)

headings.forEach(({ slug }) => {
const el = document.getElementById(slug);

if (el) {
Copy link
Member

Choose a reason for hiding this comment

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

Any chance el wouldn't exist?

Simplify to;

observe.observe(el);

});

return () => observer.disconnect();
}, [headings]);
Copy link
Member

Choose a reason for hiding this comment

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

If headings is an array, this effect will be recalculate every time the context calling this effect re-renders, because React doesn't do deep comparison of arrays/objects.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a prominent active status to the title on the right.

4 participants