GrScrollSpy
Берут, когда длинная страница с разделами.
Обзор
Когда брать
- длинная страница с разделами — регламент, документация, договор: читатель всегда видит, где находится, и может перейти к соседнему разделу не отматывая;
- оглавление сбоку у липкой колонки — пара с
GrAffix: тот держит колонку на виду, этот показывает в ней позицию чтения; - разделы внутри своей прокручиваемой области — панель, тело окна, прокручиваемая карточка: край берётся у ближайшего скроллпорта, а не у окна;
- нужны ссылки на разделы — пункт остаётся
<a href="#id">, поэтому «копировать ссылку» и открытие в новой вкладке работают сами; - своя разметка оглавления — слот на пункт отдаёт состояние и способ перейти, а вся арифметика остаётся за компонентом.
Когда взять другое
| Нужно | Берите |
|---|---|
| Разделы переключаются, в DOM живёт только текущий | GrTabs |
| Шаги процесса с пройденностью и порядком | GrSteps |
| Путь между страницами, а не внутри одной | GrBreadcrumbs |
| Само оглавление должно остаться на виду при прокрутке | GrAffix |
| Навигация по разделам приложения | GrSidebar / GrBottomNav |
Playground контролы выводятся из API, а не пишутся руками
Загружается…
0 изменено· состояние — в адресе
<GrScrollSpy :sections="sections" />Примеры 3 у каждого — живое демо и его код
Условия использования с живым оглавлением
Оглавление стоит рядом с прокручиваемой областью, а не внутри неё, и компонент сам находит скроллпорт, поднимаясь от первого раздела. Каждый пункт остаётся
<a href="#id">, поэтому средняя кнопка открывает его в новой вкладке;update-hashвыключен лишь потому, что пример не должен переписывать адрес страницы.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.
Документация с вложенными разделами
levelзадаёт отступ иaria-level, но список остаётся плоским: арифметике положения нужен порядок документа, а дерево позволило бы объявить вложенность, противоречащую порядку разделов на странице. Пока читается «With Vite», «Install» отмечен как его предок.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">Обзор предложения по комментариям
Слот
itemотдаёт строку целиком — раздел, активен ли он, иactivate, повторяющий клик. Проверяющий видит, где открытые комментарии, а отслеживание положения остаётся за компонентом; меняется только рисунок.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 порождается из пакета · разойтись не может
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
offset | string | number | undefined | undefined | Отступ линии активации от верха скроллпорта. Число — пиксели, строка — как есть. Почти всегда равен отступу липкой шапки (`--gr-affix-offset`). |
disabled | boolean | undefined | false | Выключить слежение за прокруткой: активный пункт снимается. |
ariaLabel | string | undefined | undefined | Имя лендмарка `navigation`. Не задано — берётся из локали. |
scroller | string | HTMLElement | null | undefined | undefined | Скроллпорт: элемент или селектор. Не задан — ищется от первого раздела. |
updateHash | boolean | undefined | true | Обновлять `#hash` адреса при переходе. |
behavior | ScrollBehavior | undefined | undefined | Плавность прокрутки. Под `prefers-reduced-motion` всегда мгновенная. |
focusTarget | boolean | undefined | true | Переносить фокус на раздел, как это делает переход по якорю. |
sectionsобязательный | GrScrollSpySection[] | — | Разделы в порядке их следования на странице. |
Слоты
| Slot | Type | Описание |
|---|---|---|
item |
| Пункт целиком. `activate` повторяет клик по нему. |
События
| Event | Сигнатура | Описание |
|---|---|---|
select | (event: "select", id: string, event: MouseEvent): void | — |
activeChange | (event: "activeChange", id: string | null): void | — |
Методы
| Methods | Сигнатура | Описание |
|---|---|---|
active | string | null | `id` активного раздела (ref); `null` — линия активации ещё не дошла до первого. |
scrollTo |
| Прокрутить к разделу и сразу подсветить его. Адрес и фокус, в отличие от клика, не меняет. |
refresh | () => void | Перечитать отступ, скроллпорт и положение разделов после смены разметки. |
Типы
Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.
import type { GrScrollSpyProps, GrScrollSpyEmits, GrScrollSpySection } from '@feugene/granularity/components/GrScrollSpy'Файлdist/types/components/GrScrollSpy/GrScrollSpy.vue.d.ts
GrScrollSpy — оглавление длинной страницы, которое знает, до какого раздела
дочитали: активный пункт подсвечивается по прокрутке, клик по пункту ведёт к
разделу.
Логика положения живёт в композабле useScrollSpy и доступна отдельно — этот
компонент добавляет к ней разметку, aria-current и всё, что следует из того,
что пункт является ссылкой.
interface GrScrollSpyProps {
/** Разделы в порядке их следования на странице. */
sections: GrScrollSpySection[];
/**
* Отступ линии активации от верха скроллпорта. Число — пиксели, строка — как
* есть. Почти всегда равен отступу липкой шапки (`--gr-affix-offset`).
*/
offset?: number | string;
/** Скроллпорт: элемент или селектор. Не задан — ищется от первого раздела. */
scroller?: HTMLElement | string | null;
/** Имя лендмарка `navigation`. Не задано — берётся из локали. */
ariaLabel?: string;
/** Обновлять `#hash` адреса при переходе. */
updateHash?: boolean;
/** Плавность прокрутки. Под `prefers-reduced-motion` всегда мгновенная. */
behavior?: ScrollBehavior;
/** Переносить фокус на раздел, как это делает переход по якорю. */
focusTarget?: boolean;
/** Выключить слежение за прокруткой: активный пункт снимается. */
disabled?: boolean;
}interface GrScrollSpyEmits {
/** Сменился активный раздел; `null` — линия активации ещё не дошла до первого. */
(e: 'activeChange', id: string | null): void;
/**
* Пункт выбран кликом: `id` раздела и сам клик. Клик с модификатором и `activate`
* из слота его не шлют.
*/
(e: 'select', id: string, event: MouseEvent): void;
}Файлdist/types/components/GrScrollSpy/scrollSpyItems.d.ts
Раздел в оглавлении.
interface GrScrollSpySection {
id: string;
label: string;
/** Уровень вложенности, 1 — верхний. Влияет на отступ и цепочку предков. */
level?: number;
}Файлdist/types/components/GrScrollSpy/config.d.ts
const grScrollSpyConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrScrollSpy">;Файлdist/types/components/GrScrollSpy/safelist.d.ts
const grScrollSpySafelist: string[];Файлdist/types/components/GrScrollSpy/index.d.ts
type GrScrollSpyInstance = ComponentExposed<typeof GrScrollSpyComponent>;Доступность
Паттерн APG
—
| Клавиша | Действие |
|---|---|
| — | Своего кольца стрелок нет: это навигация, а не составной виджет выбора. Каждый пункт — ссылка и своя остановка Tab, активация нативна. Перехваченный клик переносит фокус на сам раздел — то же, что делает браузер при переходе по якорю |
Полный контракт пакета: docs/keyboard.md ↗
Использование из прозы библиотеки
Активным считается раздел, чей заголовок прошли последним
Не «самый видимый». Максимум доли пересечения ошибается двумя способами: длинный раздел выигрывает у короткого независимо от того, что читают, и подсветка отскакивает назад, когда следующий раздел только показался снизу. Здесь ответ — заголовок, ближайший сверху к линии чтения, и это прямой перевод вопроса «где сейчас мой взгляд».
Порядок массива sections на ответ не влияет: выбор идёт по геометрии. Список,
разошедшийся с порядком разделов на странице, даст верную подсветку — а не
подсветку, которую невозможно отладить.
Отдельным правилом закрыт последний раздел: он часто короче остатка экрана и верхом до линии не доезжает никогда, потому что прокрутка кончается раньше. Поэтому на дне активным становится последний раздел, чем бы ни закончилась арифметика линии.
Линия активации — там же, где кончается липкая шапка
Проп offset отодвигает линию от верха скроллпорта. Число — пиксели, строка — как
есть: 4rem, var(--gr-navbar-height), calc(…). Общий отступ для страницы
задаётся не пропом, а токеном на контейнере или на :root; замер читает
вычисленный стиль, поэтому каскад работает наравне с пропом.
Число обычно одно на три места, и это стоит держать в голове:
| Где | Что |
|---|---|
--gr-affix-offset | порог прилипания у шапки |
--gr-scroll-spy-offset | линия активации |
scroll-margin-top у разделов | посадка при переходе по ссылке с якорем |
Третья строка нужна из-за второй: компонент обновляет адрес, и открытая заново ссылка обязана приземлиться туда же, куда приземляет клик.
Клик остаётся ссылкой
Пункт — <a href="#id">, и компонент перехватывает только обычный левый клик.
Cmd-клик, средняя кнопка и Shift уходят браузеру: перехватив их, компонент
отнял бы у пользователя работающее поведение и ничего не дал взамен.
Перехваченный клик прокручивает своей арифметикой, а не scrollIntoView: тот
двигает всех предков и не умеет отступ линии. Адрес обновляется через
replaceState, а не pushState — запись истории на каждый пункт превратила бы
«Назад» в отмену прокрутки. Отключается пропом updateHash.
С vue-router есть тонкость: replaceState не порождает popstate, поэтому
внутреннее состояние роутера разойдётся с адресом. Роутер-дружественный путь —
:update-hash="false" и router.replace({ hash }) из события select.
Подсветка не мигает по дороге
Клик сразу ставит активным выбранный пункт и закрепляет его, пока прокрутка не уляжется, — иначе подсветка добежала бы по всем промежуточным разделам, пока плавная прокрутка едет к цели. Закреп снимается, как только пользователь тронул прокрутку сам: колесом, пальцем, клавишей или полосой прокрутки.
Тот же закреп доступен снаружи — методом pin у композабла, — когда страницу
прокручивает не оглавление: восстановление позиции по адресу, переход к первому
полю с ошибкой.
Фокус переезжает на раздел
Перехватив клик, компонент отнимает у ссылки то, что браузер делает сам: перенос
точки последовательной навигации. Без компенсации читатель с клавиатуры после
активации пункта продолжал бы Tab внутри оглавления, а не с начала раздела.
Поэтому фокус ставится на сам раздел; нефокусируемому разделу временно выдаётся
tabindex="-1" и снимается, когда фокус уходит. Отключается пропом focusTarget.
Вложенность объявляется уровнем, а не деревом
level у раздела даёт отступ и aria-level, но структурой не является:
арифметике нужен порядок документа, и плоский массив гарантирует его
конструктивно, тогда как дерево позволило бы объявить вложенность, противоречащую
порядку разделов на странице.
Пункты-предки активного помечаются data-ancestor и выглядят не приглушённо: без
этого оглавление с двумя уровнями читается как сломанное — активен подраздел, а его
раздел выглядит выключенным. aria-current предкам не даётся: текущим может быть
только одно место.
Границы
- оглавление по заголовкам само не собирается. Список разделов передаёте вы;
компонент не ходит по DOM в поисках
h2. Замены в экосистеме нет: обход заголовков зависит от разметки страницы и пишется на стороне приложения; - горизонтального оглавления нет. Ось только вертикальная — как и у самой прокрутки, за которой компонент следит.
Пропы этого примера