Navigation
Sticky Section Bar
Горизонтальная панель разделов, прилипшая к верху длинной страницы: активный пункт сам доезжает до центра панели, когда уходит за край.
- scrollspy
- sticky
- tabs
- navigation
Превью
Использовать с AI
- 1. Скопируйте ссылку.
- 2. Напишите агенту своими словами и вставьте её в предложение.
- 3. Агент откроет ссылку и поставит компонент из registry.
размести это в шапке: https://vibeui.ru/c/scrollspy-005
О месте
Небольшая мастерская в центре города: шесть столов, общая печь и полка с инструментом.
Цены
Час работы за общим столом стоит меньше чашки кофе в соседнем кафе, абонемент на месяц выгоднее вчетверо.
Расписание
Будни с восьми утра до десяти вечера, суббота до шести, воскресенье — только для резидентов.
Правила
Громкие звонки — в телефонной будке, инструмент возвращается на полку, посуда моется сразу.
Контакты
Второй этаж, вход со двора. Пишите заранее, если нужен стол на компанию больше четырёх человек.
Показать полную инструкцию
То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.
# Установка и размещение «scrollspy-005» (Sticky Section Bar) из VibeUI
## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/scrollspy-005.json
Registry item: https://vibeui.ru/r/scrollspy-005.json
Ставится в: components/vibeui/scrollspy-005.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.
Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.
## 2. Что это
Горизонтальная панель разделов, прилипшая к верху длинной страницы: активный пункт сам доезжает до центра панели, когда уходит за край.
Липкая панель разделов с автоцентрированием активного пункта и подчёркиванием текущего. Ноль зависимостей, один файл.
## 3. Как использовать
import { Scrollspy005 } from "@/components/vibeui/scrollspy-005"
<Scrollspy005
label="Разделы страницы"
sections={[
{ id: "menu", title: "Меню", text: "Кофе, чай и выпечка" },
{ id: "prices", title: "Цены", text: "Час работы за общим столом" },
]}
/>
Полный список пропсов — в установленном файле.
## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.
Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)
## 5. Сохранить как установлено
- локальную палитру --vibeui-scrollspy-005-* и не подменять её токенами вашей темы
- sticky на обёртке панели, а не на самом списке: у списка нет запаса высоты, и он уедет сразу
- block:"nearest" в scrollIntoView — иначе к центру подтянется вся страница
- непрозрачный фон панели: текст под ней не должен просвечивать сквозь подписи
- обычные якоря без preventDefault: без JS панель обязана остаться рабочей
- aria-current на активном пункте: подчёркивание скринридеру недоступно
## 6. Можно менять
- подпись панели для скринридера через label
- разделы через sections: идентификатор, заголовок и текст
- цвет акцента через accent
- высоту области чтения в правиле height у [data-part="body"]
## 7. Правила
- scroll-margin-top у разделов должен быть не меньше высоты панели, иначе якорь спрячет заголовок под ней.
- Автоцентрирование срабатывает на каждую смену раздела: при очень быстрой прокрутке панель заметно ездит.
- Идентификаторы разделов должны быть уникальны на странице.
- Не выносите CSS-переменные в globals.css: компонент обязан оставаться одним файлом.
## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.Для разработчика
npx shadcn@latest add https://vibeui.ru/r/scrollspy-005.jsonhttps://vibeui.ru/r/scrollspy-005.jsonКомпонент самодостаточен: один файл, без зависимостей, палитра в локальных переменных --vibeui-scrollspy-005-*. Клиентский ("use client") — нужны IntersectionObserver и scrollIntoView. Панель липнет к верху области чтения: sticky стоит на обёртке nav, а прокручивается внутренний ul, потому что липнуть может только элемент, у родителя которого есть запас высоты. Активный пункт доводится до центра вызовом scrollIntoView({block:"nearest",inline:"center"}) — block:"nearest" обязателен, иначе браузер подтянет к центру всю страницу. Подсветка — подчёркивание псевдоэлементом плюс aria-current.
Исходник компонента
Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.
"use client"
import { useEffect, useRef, useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"
export type Scrollspy005Section = {
id: string
title: string
text?: string
}
export type Scrollspy005Props = Omit<
ComponentPropsWithoutRef<"div">,
"children"
> & {
sections?: Scrollspy005Section[]
label?: string
accent?: string
}
// Идея компонента: горизонтальная панель разделов, прилипшая к верху длинной
// страницы. Когда разделов больше, чем влезает в строку, подсветки мало:
// активный пункт может оказаться за краем. Поэтому он доезжает до центра
// панели сам — scrollIntoView с inline:"center" и block:"nearest", чтобы
// прокрутить именно панель и не дёрнуть при этом всю страницу.
const STYLES = `
:where([data-vibeui-block="scrollspy-005"]){
--vibeui-scrollspy-005-bg:oklch(1 0 0);
--vibeui-scrollspy-005-fg:oklch(0.23 0.014 265);
--vibeui-scrollspy-005-muted:oklch(0.56 0.014 265);
--vibeui-scrollspy-005-border:oklch(0.91 0.006 265);
--vibeui-scrollspy-005-accent:oklch(0.58 0.2 25);
--vibeui-scrollspy-005-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="scrollspy-005"]{
display:flex;flex-direction:column;
width:100%;max-width:30rem;box-sizing:border-box;
background:var(--vibeui-scrollspy-005-bg);
border:1px solid var(--vibeui-scrollspy-005-border);border-radius:0.875rem;
font-family:var(--vibeui-scrollspy-005-font);color:var(--vibeui-scrollspy-005-fg);
overflow:hidden;
}
[data-vibeui-block="scrollspy-005"] [data-part="body"]{
position:relative;height:15rem;overflow-y:auto;overscroll-behavior:contain;scroll-behavior:smooth;
}
[data-vibeui-block="scrollspy-005"] [data-part="body"]:focus-visible{outline:2px solid var(--vibeui-scrollspy-005-accent);outline-offset:-2px}
/* Панель липнет к верху области чтения и остаётся непрозрачной: текст под ней
не должен просвечивать сквозь подписи разделов. */
[data-vibeui-block="scrollspy-005"] [data-part="barwrap"]{
position:sticky;top:0;z-index:1;
background:var(--vibeui-scrollspy-005-bg);
border-bottom:1px solid var(--vibeui-scrollspy-005-border);
}
[data-vibeui-block="scrollspy-005"] [data-part="bar"]{
display:flex;gap:0.125rem;margin:0;padding:0 0.5rem;list-style:none;
overflow-x:auto;overscroll-behavior-x:contain;scrollbar-width:none;
}
[data-vibeui-block="scrollspy-005"] [data-part="bar"]::-webkit-scrollbar{display:none}
[data-vibeui-block="scrollspy-005"] [data-part="link"]{
position:relative;display:block;flex:none;
padding:0.5rem 0.5rem 0.4375rem;
color:var(--vibeui-scrollspy-005-muted);text-decoration:none;
font-size:0.8125rem;line-height:1.2;white-space:nowrap;
}
[data-vibeui-block="scrollspy-005"] [data-part="link"]:hover{color:var(--vibeui-scrollspy-005-fg)}
[data-vibeui-block="scrollspy-005"] [data-part="link"]:focus-visible{outline:2px solid var(--vibeui-scrollspy-005-accent);outline-offset:-2px;border-radius:0.25rem}
[data-vibeui-block="scrollspy-005"] [data-part="link"][aria-current="true"]{color:var(--vibeui-scrollspy-005-fg);font-weight:650}
/* Подчёркивание вместо заливки: панель остаётся тонкой, а метка — заметной. */
[data-vibeui-block="scrollspy-005"] [data-part="link"][aria-current="true"]::after{
content:"";position:absolute;left:0.5rem;right:0.5rem;bottom:-1px;height:2px;
border-radius:2px 2px 0 0;background:var(--vibeui-scrollspy-005-accent);
}
[data-vibeui-block="scrollspy-005"] [data-part="content"]{padding:0.75rem 0.9375rem 0}
[data-vibeui-block="scrollspy-005"] [data-part="section"]{scroll-margin-top:2.5rem}
[data-vibeui-block="scrollspy-005"] [data-part="section"] h4{margin:0 0 0.25rem;font-size:0.875rem;font-weight:650}
[data-vibeui-block="scrollspy-005"] [data-part="section"] p{margin:0 0 1.25rem;font-size:0.8125rem;line-height:1.5;color:var(--vibeui-scrollspy-005-muted)}
[data-vibeui-block="scrollspy-005"] [data-part="section"]:last-child p{margin-bottom:10rem}
@media (prefers-reduced-motion:reduce){
[data-vibeui-block="scrollspy-005"] [data-part="body"]{scroll-behavior:auto}
[data-vibeui-block="scrollspy-005"] *{animation:none!important;transition:none!important}
}
`
const DEFAULT_SECTIONS: Scrollspy005Section[] = [
{
id: "about",
title: "О месте",
text: "Небольшая мастерская в центре города: шесть столов, общая печь и полка с инструментом.",
},
{
id: "menu",
title: "Меню",
text: "Кофе, чай и выпечка, которую пекут здесь же утром. Список меняется каждую неделю.",
},
{
id: "prices",
title: "Цены",
text: "Час работы за общим столом стоит меньше чашки кофе в соседнем кафе, абонемент на месяц выгоднее вчетверо.",
},
{
id: "schedule",
title: "Расписание",
text: "Будни с восьми утра до десяти вечера, суббота до шести, воскресенье — только для резидентов.",
},
{
id: "rules",
title: "Правила",
text: "Громкие звонки — в телефонной будке, инструмент возвращается на полку, посуда моется сразу.",
},
{
id: "contacts",
title: "Контакты",
text: "Второй этаж, вход со двора. Пишите заранее, если нужен стол на компанию больше четырёх человек.",
},
]
/**
* Липкая панель разделов: активный пункт сам доезжает до центра панели.
* Один файл, ноль зависимостей, собственная палитра.
*/
export function Scrollspy005({
sections = DEFAULT_SECTIONS,
label = "Разделы страницы",
accent,
className,
style,
...props
}: Scrollspy005Props) {
const body = useRef<HTMLDivElement>(null)
const bar = useRef<HTMLUListElement>(null)
const [active, setActive] = useState(sections[0]?.id)
useEffect(() => {
const root = body.current
if (!root) return
const watcher = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort(
(a, b) => a.boundingClientRect.top - b.boundingClientRect.top,
)[0]
if (visible) setActive(visible.target.id)
},
{ root, rootMargin: "-2.5rem 0px -65% 0px", threshold: 0 },
)
root
.querySelectorAll("[data-part='section']")
.forEach((section) => watcher.observe(section))
return () => watcher.disconnect()
}, [sections])
useEffect(() => {
// block:"nearest" обязателен: иначе браузер подтянет к центру всю страницу.
bar.current
?.querySelector(`[data-part="link"][aria-current="true"]`)
?.scrollIntoView({ block: "nearest", inline: "center" })
}, [active])
const palette = {
...(accent ? { "--vibeui-scrollspy-005-accent": accent } : null),
...style,
} as CSSProperties
return (
<>
<style href="vibeui-scrollspy-005" precedence="medium">
{STYLES}
</style>
<div
{...props}
data-vibeui-block="scrollspy-005"
className={className}
style={palette}
>
<div
data-part="body"
ref={body}
tabIndex={0}
role="group"
aria-label="Текст страницы"
>
<nav data-part="barwrap" aria-label={label}>
<ul data-part="bar" ref={bar}>
{sections.map((section) => (
<li key={section.id}>
<a
data-part="link"
href={`#${section.id}`}
aria-current={section.id === active}
>
{section.title}
</a>
</li>
))}
</ul>
</nav>
<div data-part="content">
{sections.map((section) => (
<section key={section.id} id={section.id} data-part="section">
<h4>{section.title}</h4>
<p>{section.text}</p>
</section>
))}
</div>
</div>
</div>
</>
)
}