Navigation

Link Pagination

Постраничная навигация обычными ссылками: страницу можно открыть в новой вкладке, скопировать и найти в истории. Окно номеров вокруг текущей.

  • pagination
  • navigation
  • links
  • list

Превью

1440pxHost theme

Использовать с AI

  1. 1. Скопируйте ссылку.
  2. 2. Напишите агенту своими словами и вставьте её в предложение.
  3. 3. Агент откроет ссылку и поставит компонент из registry.

размести это в шапке: https://vibeui.ru/c/pagination-001?page=12

Показать полную инструкцию

То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.

# Установка и размещение «pagination-001» (Link Pagination) из VibeUI

## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/pagination-001.json

Registry item: https://vibeui.ru/r/pagination-001.json
Ставится в: components/vibeui/pagination-001.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.

Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.

## 2. Что это
Постраничная навигация обычными ссылками: страницу можно открыть в новой вкладке, скопировать и найти в истории. Окно номеров вокруг текущей.

Постраничная навигация ссылками: стрелки по краям, окно номеров вокруг текущей страницы и разрывы вместо длинного списка. Ширина ряда не скачет при переходе, потому что края остаются на месте. Ноль зависимостей, один файл, своя палитра, клиентского JS нет.

## 3. Как использовать
import { Pagination001 } from "@/components/vibeui/pagination-001"

<Pagination001
  page={4}
  total={12}
  hrefOf={(page) => `/blog?page=${page}`}
/>

Полный список пропсов — в установленном файле.

## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.

Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)

## 5. Сохранить как установлено
- локальную палитру --vibeui-pagination-001-* и не подменять её токенами вашей темы (bg-primary, border-input и подобными)
- номера как ссылки, а не кнопки: открыть в новой вкладке, скопировать адрес и вернуться из истории — всё это идёт бесплатно
- aria-current="page" у текущей страницы: без него активный номер отличается только цветом
- края, которые гасятся, а не исчезают: пропадающая стрелка сдвигает весь ряд
- tabular-nums у номеров — без них ширина кнопок пляшет от цифр
- подписи aria-label у стрелок: «←» скринридер прочитает как символ
- блок <style> внутри компонента — в нём вся палитра и раскладка

## 6. Можно менять
- page и total — текущая страница и общее количество
- siblings — сколько номеров показывать вокруг текущей
- hrefOf — как собрать адрес страницы под ваш роутер
- акцент через проп accent — цвет активного номера
- внешние отступы через className

## 7. Правила
- Для бесконечной ленты пагинация не нужна: это разные способы чтения списка.
- Не заменяйте ссылки на onClick без адреса: тогда страницу нельзя открыть в новой вкладке и она пропадёт из истории.
- Общее количество страниц должно быть известно. Если его нет, показывайте «дальше» без номеров.
- Не выносите CSS-переменные в globals.css: компонент обязан оставаться одним файлом.

## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.

Для разработчика

npx shadcn@latest add https://vibeui.ru/r/pagination-001.json
https://vibeui.ru/r/pagination-001.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-pagination-001-*. Номера — обычные <a>, адрес собирает функция hrefOf, по умолчанию `?page=N`. Окно вокруг текущей страницы считает windowOf: первая, последняя, соседи и разрывы между ними. Текущая страница помечена aria-current="page". Края не исчезают на первой и последней странице, а гасятся через aria-disabled, поэтому ряд не прыгает.

Исходник компонента

Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.

import type { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Pagination001Props = Omit<
  ComponentPropsWithoutRef<"nav">,
  "children"
> & {
  page?: number
  total?: number
  /** Сколько номеров показывать вокруг текущего. */
  siblings?: number
  /** Как собрать адрес страницы. По умолчанию `?page=N`. */
  hrefOf?: (page: number) => string
  accent?: string
}

// Идея компонента: номера — обычные ссылки, а не кнопки с обработчиком.
// Страницу можно открыть в новой вкладке, скопировать и найти в истории,
// поисковик пройдёт по списку. Окно номеров вокруг текущего считается заранее,
// поэтому ширина блока не скачет при переходе.
const STYLES = `
:where([data-vibeui-block="pagination-001"]){
--vibeui-pagination-001-fg:oklch(0.26 0.016 265);
--vibeui-pagination-001-muted:oklch(0.55 0.014 265);
--vibeui-pagination-001-border:oklch(0.9 0.006 265);
--vibeui-pagination-001-hover:oklch(0.55 0.02 265 / 8%);
--vibeui-pagination-001-accent:oklch(0.55 0.2 262);
--vibeui-pagination-001-accent-fg:oklch(1 0 0);
--vibeui-pagination-001-size:2.25rem;
--vibeui-pagination-001-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="pagination-001"]{font-family:var(--vibeui-pagination-001-font)}
[data-vibeui-block="pagination-001"] ul{
display:flex;align-items:center;gap:0.25rem;margin:0;padding:0;list-style:none;
}
[data-vibeui-block="pagination-001"] a,
[data-vibeui-block="pagination-001"] [data-part="gap"]{
display:inline-flex;align-items:center;justify-content:center;
min-width:var(--vibeui-pagination-001-size);height:var(--vibeui-pagination-001-size);
padding:0 0.5rem;border-radius:0.5rem;
font-size:0.875rem;line-height:1;text-decoration:none;
color:var(--vibeui-pagination-001-fg);
font-variant-numeric:tabular-nums;
transition:background-color .16s ease,color .16s ease,border-color .16s ease;
}
[data-vibeui-block="pagination-001"] a:hover{background:var(--vibeui-pagination-001-hover)}
[data-vibeui-block="pagination-001"] a:focus-visible{outline:2px solid var(--vibeui-pagination-001-accent);outline-offset:2px}
[data-vibeui-block="pagination-001"] [data-part="gap"]{color:var(--vibeui-pagination-001-muted);cursor:default}
[data-vibeui-block="pagination-001"] [aria-current="page"]{
background:var(--vibeui-pagination-001-accent);color:var(--vibeui-pagination-001-accent-fg);font-weight:600;
}
[data-vibeui-block="pagination-001"] [aria-current="page"]:hover{background:var(--vibeui-pagination-001-accent)}
[data-vibeui-block="pagination-001"] [data-part="edge"]{
border:1px solid var(--vibeui-pagination-001-border);color:var(--vibeui-pagination-001-muted);
}
/* Недоступный край остаётся на месте: без него ряд прыгает на первой странице. */
[data-vibeui-block="pagination-001"] [data-part="edge"][aria-disabled="true"]{
opacity:.4;pointer-events:none;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="pagination-001"] *{animation:none!important;transition:none!important}}
`

/** Окно номеров вокруг текущей страницы с краями и разрывами. */
function windowOf(page: number, total: number, siblings: number) {
  const pages = new Set<number>([1, total])

  for (let index = page - siblings; index <= page + siblings; index += 1) {
    if (index >= 1 && index <= total) {
      pages.add(index)
    }
  }

  const sorted = [...pages].sort((a, b) => a - b)
  const result: (number | "gap")[] = []

  sorted.forEach((value, index) => {
    if (index > 0 && value - sorted[index - 1] > 1) {
      result.push("gap")
    }

    result.push(value)
  })

  return result
}

/**
 * Постраничная навигация ссылками: окно номеров вокруг текущей страницы.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Pagination001({
  page = 4,
  total = 12,
  siblings = 1,
  hrefOf = (value) => `?page=${value}`,
  accent,
  className,
  style,
  ...props
}: Pagination001Props) {
  const palette = {
    ...(accent ? { "--vibeui-pagination-001-accent": accent } : null),
    ...style,
  } as CSSProperties

  const items = windowOf(page, total, siblings)

  return (
    <>
      <style href="vibeui-pagination-001" precedence="medium">
        {STYLES}
      </style>
      <nav
        {...props}
        data-vibeui-block="pagination-001"
        aria-label="Страницы"
        className={className}
        style={palette}
      >
        <ul>
          <li>
            <a
              data-part="edge"
              href={hrefOf(Math.max(1, page - 1))}
              aria-disabled={page === 1 || undefined}
              aria-label="Предыдущая страница"
            >
              ←
            </a>
          </li>
          {items.map((item, index) =>
            item === "gap" ? (
              <li key={`gap-${index}`}>
                <span data-part="gap" aria-hidden="true">
                  …
                </span>
              </li>
            ) : (
              <li key={item}>
                <a
                  href={hrefOf(item)}
                  aria-current={item === page ? "page" : undefined}
                >
                  {item}
                </a>
              </li>
            ),
          )}
          <li>
            <a
              data-part="edge"
              href={hrefOf(Math.min(total, page + 1))}
              aria-disabled={page === total || undefined}
              aria-label="Следующая страница"
            >
              →
            </a>
          </li>
        </ul>
      </nav>
    </>
  )
}