GrScrollSpy
A table of contents that highlights the section you are reading and scrolls to the one you pick.
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
| Need | Take |
|---|---|
| The sections are switched, only the current one lives in the DOM | GrTabs |
| The steps of a process with completion and order | GrSteps |
| A path between pages rather than within one | GrBreadcrumbs |
| The table of contents itself must stay in view while scrolling | GrAffix |
| Navigation across the sections of an application | GrSidebar / GrBottomNav |
Playground controls come from the API, not from hand
Loading…
0 changed· state is in the URL
<GrScrollSpy :sections="sections" />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-hashis 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
levelsets the indent andaria-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 vueWith 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
itemslot hands over the whole row — the section, whether it is active, andactivate, 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
| Prop | Type | default | Description |
|---|---|---|---|
offset | string | number | undefined | undefined | The 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`). |
disabled | boolean | undefined | false | Turn off scroll tracking: the active item is cleared. |
ariaLabel | string | undefined | undefined | The name of the `navigation` landmark. Unset — taken from the locale. |
scroller | string | HTMLElement | null | undefined | undefined | The scrollport: an element or a selector. Not set — it is looked up from the first section. |
updateHash | boolean | undefined | true | Update the `#hash` of the address on a move. |
behavior | ScrollBehavior | undefined | undefined | The smoothness of the scrolling. Under `prefers-reduced-motion` it is always instant. |
focusTarget | boolean | undefined | true | Move the focus to the section, the way a move by an anchor does. |
sectionsrequired | GrScrollSpySection[] | — | The sections in the order they follow on the page. |
Slots
| Slot | Type | Description |
|---|---|---|
item |
| The item as a whole. `activate` repeats a click on it. |
Events
| Event | Signature | Description |
|---|---|---|
select | (event: "select", id: string, event: MouseEvent): void | — |
activeChange | (event: "activeChange", id: string | null): void | — |
Methods
| Methods | Signature | Description |
|---|---|---|
active | string | null | The `id` of the active section (ref); `null` — the activation line has not reached the first one yet. |
scrollTo |
| Scroll to a section and highlight it straight away. Unlike a click, it changes neither the address nor the focus. |
refresh | () => void | Re-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 type { GrScrollSpyProps, GrScrollSpyEmits, GrScrollSpySection } from '@feugene/granularity/components/GrScrollSpy'Filedist/types/components/GrScrollSpy/GrScrollSpy.vue.d.ts
interface GrScrollSpyProps {
sections: GrScrollSpySection[];
offset?: number | string;
scroller?: HTMLElement | string | null;
ariaLabel?: string;
updateHash?: boolean;
behavior?: ScrollBehavior;
focusTarget?: boolean;
disabled?: boolean;
}interface GrScrollSpyEmits {
(e: 'activeChange', id: string | null): void;
(e: 'select', id: string, event: MouseEvent): void;
}Filedist/types/components/GrScrollSpy/scrollSpyItems.d.ts
interface GrScrollSpySection {
id: string;
label: string;
level?: number;
}Filedist/types/components/GrScrollSpy/config.d.ts
const grScrollSpyConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrScrollSpy">;Filedist/types/components/GrScrollSpy/safelist.d.ts
const grScrollSpySafelist: string[];Filedist/types/components/GrScrollSpy/index.d.ts
type GrScrollSpyInstance = ComponentExposed<typeof GrScrollSpyComponent>;Accessibility
APG pattern
—
Full contract of the package: docs/keyboard.md ↗
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:
| Where | What |
|---|---|
--gr-affix-offset | the sticking threshold at the header |
--gr-scroll-spy-offset | the activation line |
scroll-margin-top on the sections | the 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.
A click remains a link
An item is an <a href="#id">, and the component intercepts only an ordinary left click.
Cmd-click, the middle button and Shift go to the browser: by intercepting them the component
would take a working behaviour away from the user and give nothing in return.
An intercepted click scrolls with arithmetic of its own rather than with scrollIntoView: that one
moves every ancestor and knows nothing of the offset of the line. The address is updated through
replaceState rather than pushState — a history entry for every item would turn “Back” into an
undo of the scroll. Switched off with the updateHash prop.
There is a subtlety with vue-router: replaceState does not produce a popstate, so the
internal state of the router will diverge from the address. The router-friendly path is
:update-hash="false" and router.replace({ hash }) from the select event.
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.
Props in this example