GrScrollSpy

  • core
  • navigation
  • API · 8 props
  • ⌨ keys

A table of contents that highlights the section you are reading and scrolls to the one you pick.

import { GrScrollSpy } from '@feugene/granularity/components/GrScrollSpy'

Overview

When to take it

  • a long page with sections — a regulation, documentation, a contract: the reader always sees where they are and can move to a neighbouring section without scrolling back;
  • a table of contents at the side of a sticky column — a pair with GrAffix: that one keeps the column in view, this one shows the reading position in it;
  • sections inside a scrollable area of their own — a panel, the body of a window, a scrollable card: the edge is taken from the nearest scrollport rather than from the window;
  • links to the sections are needed — an item remains an <a href="#id">, so “copy link” and opening in a new tab work by themselves;
  • markup of your own for the table of contents — the per-item slot gives away the state and a way to navigate, while all the arithmetic stays with the component.

When to take something else

NeedTake
The sections are switched, only the current one lives in the DOMGrTabs
The steps of a process with completion and orderGrSteps
A path between pages rather than within oneGrBreadcrumbs
The table of contents itself must stay in view while scrollingGrAffix
Navigation across the sections of an applicationGrSidebar / GrBottomNav

Playground controls come from the API, not from hand

Stage

Loading…

0 changed· state is in the URL

Examples 3 each with a live demo and its code

  • Terms of service with a live outline

    The outline stands beside the scrolling area, not inside it, and the component finds the scrollport itself, walking up from the first section. Each item stays an <a href="#id">, so a middle click opens it in a new tab; update-hash is off here only because a demo should not rewrite the page address.

    Your account

    You are responsible for what happens under your account. Keep your password private and turn on two-factor sign-in for every owner of a workspace.

    Last changed on 1 September 2026.

    Billing

    Plans are billed monthly or yearly in advance. Seats added during a period are charged pro rata on the next invoice; removed seats are credited the same way.

    Last changed on 1 September 2026.

    Your data

    Invoices, customers and files you upload stay yours. We process them only to run the service and keep them in the EU region you chose when the workspace was created.

    Last changed on 1 September 2026.

    Availability

    We aim for 99.9% monthly uptime. Planned maintenance is announced on the status page at least 48 hours ahead and never falls on the first working day of a month.

    Last changed on 1 September 2026.

    Ending the service

    You can close the workspace at any time from Settings. Data stays exportable for 30 days, after which it is deleted from backups as well.

    Last changed on 1 September 2026.

  • Documentation with nested sections

    level sets the indent and aria-level, but the list stays flat: the position arithmetic needs document order, and a tree would allow a nesting that contradicts the order of the sections on the page. While «With Vite» is read, «Install» is marked as its ancestor.

    Install

    Add the package and its peer dependency. The library ships ES modules and works with any bundler that understands them.

    npm i @feugene/granularity vue

    With Vite

    Register the plugin once; it wires the stylesheet and the icon set into the build.

    plugins: [vue(), granularity()]

    With Nuxt

    Use the module instead of the plugin: components are auto-imported and render on the server.

    modules: ['@feugene/granularity/nuxt']

    Theme

    Colours are roles, not values: change a role once and every component follows.

    --gr-primary: oklch(0.55 0.2 270);

    Dark mode

    Set data-theme on the root element, or let the system decide with prefers-color-scheme.

    <html data-theme="dark">
  • Reviewing a proposal by its comments

    The item slot hands over the whole row — the section, whether it is active, and activate, which repeats the click. The reviewer sees where the open comments are, while position tracking stays with the component; only the drawing changes.

    Summary

    Northwind replaces three regional billing tools with one, cutting month-end close from six days to two.

    Scope

    In scope: invoicing, dunning and revenue reports for the EU entities. Out of scope: payroll and the US entity until Q2.

    2 open comments from finance

    Risks

    Historic invoices before 2021 are stored as PDFs only; importing them needs manual review of about 4,000 documents.

    3 open comments from finance

    Budget

    €86,000 one-off for migration and training, then €2,900 a month for 60 seats — below the three tools combined.

    1 open comment from finance

    Timeline

    Pilot with the Irish entity in November, the rest of the EU in January, old tools switched off in March.

API generated from the package · cannot drift

Props

PropTypedefaultDescription
offsetstring | number | undefinedundefinedThe offset of the activation line from the top of the scrollport. A number is pixels, a string is taken as is. Almost always equals the offset of the sticky header (`--gr-affix-offset`).
disabledboolean | undefinedfalseTurn off scroll tracking: the active item is cleared.
ariaLabelstring | undefinedundefinedThe name of the `navigation` landmark. Unset — taken from the locale.
scrollerstring | HTMLElement | null | undefinedundefinedThe scrollport: an element or a selector. Not set — it is looked up from the first section.
updateHashboolean | undefinedtrueUpdate the `#hash` of the address on a move.
behaviorScrollBehavior | undefinedundefinedThe smoothness of the scrolling. Under `prefers-reduced-motion` it is always instant.
focusTargetboolean | undefinedtrueMove the focus to the section, the way a move by an anchor does.
sectionsrequiredGrScrollSpySection[]—The sections in the order they follow on the page.

Slots

SlotTypeDescription
item
{ section: GrScrollSpySection; active: boolean; ancestor: bo{ section: GrScrollSpySection; active: boolean; ancestor: boolean; activate: () => void; }
The item as a whole. `activate` repeats a click on it.

Events

EventSignatureDescription
select(event: "select", id: string, event: MouseEvent): void—
activeChange(event: "activeChange", id: string | null): void—

Methods

MethodsSignatureDescription
activestring | nullThe `id` of the active section (ref); `null` — the activation line has not reached the first one yet.
scrollTo
(id: string, options?: { behavior?: ScrollBehavior | undefin(id: string, options?: { behavior?: ScrollBehavior | undefined; } | undefined) => void
Scroll to a section and highlight it straight away. Unlike a click, it changes neither the address nor the focus.
refresh() => voidRe-read the offset, the scrollport and the position of the sections after the markup changes.

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrScrollSpyProps, GrScrollSpyEmits, GrScrollSpySection } from '@feugene/granularity/components/GrScrollSpy'

Filedist/types/components/GrScrollSpy/GrScrollSpy.vue.d.ts

GrScrollSpyPropsinterface
interface GrScrollSpyProps {
    sections: GrScrollSpySection[];
    offset?: number | string;
    scroller?: HTMLElement | string | null;
    ariaLabel?: string;
    updateHash?: boolean;
    behavior?: ScrollBehavior;
    focusTarget?: boolean;
    disabled?: boolean;
}
GrScrollSpyEmitsinterface
interface GrScrollSpyEmits {
    (e: 'activeChange', id: string | null): void;
    (e: 'select', id: string, event: MouseEvent): void;
}

Filedist/types/components/GrScrollSpy/scrollSpyItems.d.ts

GrScrollSpySectioninterface
interface GrScrollSpySection {
    id: string;
    label: string;
    level?: number;
}

Filedist/types/components/GrScrollSpy/config.d.ts

grScrollSpyConfigconst
const grScrollSpyConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrScrollSpy">;

Filedist/types/components/GrScrollSpy/safelist.d.ts

grScrollSpySafelistconst
const grScrollSpySafelist: string[];

Filedist/types/components/GrScrollSpy/index.d.ts

GrScrollSpyInstancetype
type GrScrollSpyInstance = ComponentExposed<typeof GrScrollSpyComponent>;

Accessibility

APG pattern

—

Usage from the library prose

The active section is the one whose heading was passed last

Not “the most visible one”. The maximum of the intersection ratio errs in two ways: a long section wins over a short one regardless of what is being read, and the highlight bounces back when the next section has only just shown up from below. Here the answer is the heading closest above the reading line, and that is a direct translation of the question “where is my gaze now”.

The order of the sections array does not affect the answer: the choice goes by geometry. A list that has diverged from the order of the sections on the page will give a correct highlight — rather than a highlight that is impossible to debug.

The last section is covered by a separate rule: it is often shorter than the rest of the screen and never reaches the line with its top, because the scroll ends earlier. So at the bottom the last section becomes active, whatever the arithmetic of the line ends with.

The activation line is where the sticky header ends

The offset prop moves the line away from the top of the scrollport. A number is pixels, a string is taken as is: 4rem, var(--gr-navbar-height), calc(…). A shared offset for the page is set not with the prop but with a token on the container or on :root; the measurement reads the computed style, so the cascade works on a par with the prop.

The number is usually one for three places, and that is worth keeping in mind:

WhereWhat
--gr-affix-offsetthe sticking threshold at the header
--gr-scroll-spy-offsetthe activation line
scroll-margin-top on the sectionsthe landing when following a link with an anchor

The third row is needed because of the second: the component updates the address, and a link opened anew must land in the same place a click lands.

The highlight does not flicker on the way

A click makes the chosen item active at once and pins it until the scroll settles — otherwise the highlight would run through every intermediate section while the smooth scroll travels to the target. The pin is released as soon as the user has touched the scroll themselves: with the wheel, a finger, a key or the scrollbar.

The same pin is available from outside — with the pin method of the composable — when the page is scrolled by something other than the table of contents: restoring the position from the address, moving to the first field with an error.

The focus moves to the section

By intercepting the click the component takes away from the link what the browser does by itself: moving the point of sequential navigation. Without compensation a keyboard reader, after activating an item, would continue with Tab inside the table of contents rather than from the beginning of the section. So the focus is placed on the section itself; a non-focusable section is temporarily given tabindex="-1", which is removed when the focus leaves. Switched off with the focusTarget prop.

Nesting is declared with a level, not with a tree

The level of a section gives the indentation and aria-level but is not a structure: the arithmetic needs the document order, and a flat array guarantees it by construction, whereas a tree would allow declaring a nesting that contradicts the order of the sections on the page.

The ancestor items of the active one are marked with data-ancestor and do not look muted: without that a table of contents with two levels reads as broken — a subsection is active while its section looks switched off. aria-current is not given to the ancestors: only one place can be current.

The limits
  • the table of contents is not assembled from the headings by itself. The list of sections is passed by you; the component does not walk the DOM looking for h2. There is no replacement in the ecosystem: walking the headings depends on the markup of the page and is written on the application side;
  • there is no horizontal table of contents. The axis is vertical only — as with the scroll itself, which the component watches.