GrScrollSpy

  • ядро
  • Навигация
  • API · 8 props
  • ⌨ клавиши

Берут, когда длинная страница с разделами.

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

Обзор

Когда брать

  • длинная страница с разделами — регламент, документация, договор: читатель всегда видит, где находится, и может перейти к соседнему разделу не отматывая;
  • оглавление сбоку у липкой колонки — пара с GrAffix: тот держит колонку на виду, этот показывает в ней позицию чтения;
  • разделы внутри своей прокручиваемой области — панель, тело окна, прокручиваемая карточка: край берётся у ближайшего скроллпорта, а не у окна;
  • нужны ссылки на разделы — пункт остаётся <a href="#id">, поэтому «копировать ссылку» и открытие в новой вкладке работают сами;
  • своя разметка оглавления — слот на пункт отдаёт состояние и способ перейти, а вся арифметика остаётся за компонентом.

Когда взять другое

НужноБерите
Разделы переключаются, в DOM живёт только текущийGrTabs
Шаги процесса с пройденностью и порядкомGrSteps
Путь между страницами, а не внутри однойGrBreadcrumbs
Само оглавление должно остаться на виду при прокруткеGrAffix
Навигация по разделам приложенияGrSidebar / GrBottomNav

Playground контролы выводятся из API, а не пишутся руками

Сцена

Загружается…

0 изменено· состояние — в адресе

Примеры 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 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">
  • Обзор предложения по комментариям

    Слот 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

PropTypeпо умолчаниюОписание
offsetstring | number | undefinedundefinedОтступ линии активации от верха скроллпорта. Число — пиксели, строка — как есть. Почти всегда равен отступу липкой шапки (`--gr-affix-offset`).
disabledboolean | undefinedfalseВыключить слежение за прокруткой: активный пункт снимается.
ariaLabelstring | undefinedundefinedИмя лендмарка `navigation`. Не задано — берётся из локали.
scrollerstring | HTMLElement | null | undefinedundefinedСкроллпорт: элемент или селектор. Не задан — ищется от первого раздела.
updateHashboolean | undefinedtrueОбновлять `#hash` адреса при переходе.
behaviorScrollBehavior | undefinedundefinedПлавность прокрутки. Под `prefers-reduced-motion` всегда мгновенная.
focusTargetboolean | undefinedtrueПереносить фокус на раздел, как это делает переход по якорю.
sectionsобязательныйGrScrollSpySection[]—Разделы в порядке их следования на странице.

Слоты

SlotTypeОписание
item
{ section: GrScrollSpySection; active: boolean; ancestor: bo{ section: GrScrollSpySection; active: boolean; ancestor: boolean; activate: () => void; }
Пункт целиком. `activate` повторяет клик по нему.

События

EventСигнатураОписание
select(event: "select", id: string, event: MouseEvent): void—
activeChange(event: "activeChange", id: string | null): void—

Методы

MethodsСигнатураОписание
activestring | null`id` активного раздела (ref); `null` — линия активации ещё не дошла до первого.
scrollTo
(id: string, options?: { behavior?: ScrollBehavior | undefin(id: string, options?: { behavior?: ScrollBehavior | undefined; } | undefined) => void
Прокрутить к разделу и сразу подсветить его. Адрес и фокус, в отличие от клика, не меняет.
refresh() => voidПеречитать отступ, скроллпорт и положение разделов после смены разметки.

Типы

Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.

Импорт
import type { GrScrollSpyProps, GrScrollSpyEmits, GrScrollSpySection } from '@feugene/granularity/components/GrScrollSpy'

Файлdist/types/components/GrScrollSpy/GrScrollSpy.vue.d.ts

GrScrollSpyPropsinterface

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;
}
GrScrollSpyEmitsinterface
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

GrScrollSpySectioninterface

Раздел в оглавлении.

interface GrScrollSpySection {
    id: string;
    label: string;
    /** Уровень вложенности, 1 — верхний. Влияет на отступ и цепочку предков. */
    level?: number;
}

Файлdist/types/components/GrScrollSpy/config.d.ts

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

Файлdist/types/components/GrScrollSpy/safelist.d.ts

grScrollSpySafelistconst
const grScrollSpySafelist: string[];

Файлdist/types/components/GrScrollSpy/index.d.ts

GrScrollSpyInstancetype
type GrScrollSpyInstance = ComponentExposed<typeof GrScrollSpyComponent>;

Доступность

Паттерн APG

—

КлавишаДействие
—Своего кольца стрелок нет: это навигация, а не составной виджет выбора. Каждый пункт — ссылка и своя остановка Tab, активация нативна. Перехваченный клик переносит фокус на сам раздел — то же, что делает браузер при переходе по якорю

Использование из прозы библиотеки

Активным считается раздел, чей заголовок прошли последним

Не «самый видимый». Максимум доли пересечения ошибается двумя способами: длинный раздел выигрывает у короткого независимо от того, что читают, и подсветка отскакивает назад, когда следующий раздел только показался снизу. Здесь ответ — заголовок, ближайший сверху к линии чтения, и это прямой перевод вопроса «где сейчас мой взгляд».

Порядок массива 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. Замены в экосистеме нет: обход заголовков зависит от разметки страницы и пишется на стороне приложения;
  • горизонтального оглавления нет. Ось только вертикальная — как и у самой прокрутки, за которой компонент следит.