Navigation

Thumb Pagination

Pagination for a phone: three large targets instead of a row of numbers, with the full page list opening as a grid in a popover.

  • pagination
  • mobile
  • touch
  • popover

Preview

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 3. The agent opens the link and installs the component from the registry.

put this in the header: https://vibeui.ru/c/pagination-009?page=14&lang=en

Show the full instructions

What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.

# Install and place "pagination-009" (Thumb Pagination) from VibeUI

## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/pagination-009.json

Registry item: https://vibeui.ru/r/pagination-009.json
Installs to: components/vibeui/pagination-009.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.

Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.

## 2. What it is
Pagination for a phone: three large targets instead of a row of numbers, with the full page list opening as a grid in a popover.

Compact phone pagination: large arrows at the edges and a current-page button in the middle that opens a grid of every number. Every step is a link, so a page opens in a new tab and stays in history. Zero dependencies, one file, no client JS.

## 3. How to use it
import { Pagination009 } from "@/components/vibeui/pagination-009"

<Pagination009 page={3} total={18} hrefOf={(page) => `/catalog?page=${page}`} />

Read the installed file for the full prop list.

## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.

Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)

## 5. Keep exactly as installed
- the local --vibeui-pagination-009-* palette — do not swap it for your theme tokens (bg-card, border-input and the like)
- targets no smaller than 2.75rem: a row of small numbers cannot be hit with a thumb
- the number grid in a popover instead of a row: eighteen numbers do not fit across a narrow screen
- popover="auto": Escape and a tap outside close the grid without your code
- aria-current="page" on the current number both in the list and on the indicator button
- the aria-labels on the arrows: a border-drawn arrow has no text and is nameless without one

## 6. You may change
- page and total — the current page and the count
- hrefOf — how to build a page address for your router
- the accent through the accent prop — it colours the active number and the focus rings
- the target size and the number of grid columns

## 7. Rules
- The grid lists every page: at several hundred it becomes a long scrolling list.
- Without anchor positioning support the grid opens centred: acceptable on a phone, but check it.
- The component is meant for narrow widths: on desktop take the row of numbers.
- Do not lift the CSS variables into globals.css: the component has to stay a single file.

## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.

For developers

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

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-pagination-009-*. Серверный: хуков нет, все номера — обычные ссылки. Цели по 2.75rem — нижняя граница для касания пальцем. Сетка страниц лежит в popover="auto" с якорем на самой панели, поэтому закрытие по Escape и по касанию мимо достаётся от браузера. Текущая страница помечена aria-current="page", края гасятся через aria-disabled.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

import type { CSSProperties } from "react"

export type Pagination009Props = {
  page?: number
  total?: number
  hrefOf?: (page: number) => string
  accent?: string
  className?: string
  style?: CSSProperties
}

// Идея компонента: пагинация для телефона. Ряд номеров на узком экране нечитаем
// и в него не попасть пальцем, поэтому здесь три крупные цели: назад, вперёд и
// текущая страница, открывающая сетку всех номеров. Сетка — HTML popover: она
// закрывается по Escape и по касанию мимо без единой строки JS.
const STYLES = `
:where([data-vibeui-block="pagination-009"]){
--vibeui-pagination-009-bg:oklch(1 0 0);
--vibeui-pagination-009-fg:oklch(0.24 0.014 265);
--vibeui-pagination-009-muted:oklch(0.55 0.014 265);
--vibeui-pagination-009-border:oklch(0.91 0.006 265);
--vibeui-pagination-009-hover:oklch(0.55 0.02 265 / 8%);
--vibeui-pagination-009-accent:oklch(0.55 0.2 262);
--vibeui-pagination-009-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="pagination-009"]{
box-sizing:border-box;width:100%;max-width:20rem;padding:0.375rem;
display:flex;align-items:center;gap:0.375rem;
background:var(--vibeui-pagination-009-bg);color:var(--vibeui-pagination-009-fg);
border:1px solid var(--vibeui-pagination-009-border);border-radius:0.875rem;
font-family:var(--vibeui-pagination-009-font);
anchor-name:--vibeui-pagination-009-bar;
}
/* Цели по 2.75rem: меньше пальцем не попасть, это нижняя граница для касания. */
[data-vibeui-block="pagination-009"] [data-part="step"]{
width:2.75rem;height:2.75rem;flex:none;
display:inline-grid;place-items:center;
border:1px solid var(--vibeui-pagination-009-border);border-radius:0.75rem;
text-decoration:none;color:inherit;
}
[data-vibeui-block="pagination-009"] [data-part="step"]:hover{background:var(--vibeui-pagination-009-hover)}
[data-vibeui-block="pagination-009"] [data-part="step"]:focus-visible{outline:2px solid var(--vibeui-pagination-009-accent);outline-offset:2px}
[data-vibeui-block="pagination-009"] [data-part="step"][aria-disabled="true"]{opacity:.4;pointer-events:none}
[data-vibeui-block="pagination-009"] [data-part="arrow"]{
width:0.5rem;height:0.5rem;
border:1.75px solid currentColor;border-right:0;border-bottom:0;transform:rotate(-45deg);
margin-left:0.1875rem;
}
[data-vibeui-block="pagination-009"] [data-part="step"][data-dir="next"] [data-part="arrow"]{transform:rotate(135deg);margin:0 0.1875rem 0 0}
[data-vibeui-block="pagination-009"] [data-part="current"]{
flex:1;height:2.75rem;appearance:none;cursor:pointer;
display:inline-flex;align-items:center;justify-content:center;gap:0.375rem;
border:1px solid var(--vibeui-pagination-009-border);border-radius:0.75rem;
background:none;color:inherit;font:inherit;font-size:0.9375rem;
font-variant-numeric:tabular-nums;
}
[data-vibeui-block="pagination-009"] [data-part="current"]:focus-visible{outline:2px solid var(--vibeui-pagination-009-accent);outline-offset:2px}
[data-vibeui-block="pagination-009"] [data-part="of"]{color:var(--vibeui-pagination-009-muted);font-size:0.8125rem}
[data-vibeui-block="pagination-009"] [data-part="sheet"]{
position:fixed;inset:auto;margin:0;
width:min(18rem,92vw);max-height:14rem;overflow-y:auto;
padding:0.5rem;box-sizing:border-box;
background:var(--vibeui-pagination-009-bg);color:var(--vibeui-pagination-009-fg);
border:1px solid var(--vibeui-pagination-009-border);border-radius:0.875rem;
font-family:var(--vibeui-pagination-009-font);
box-shadow:0 24px 48px -24px oklch(0.2 0.03 265 / 40%);
}
@supports (anchor-name: --a){
[data-vibeui-block="pagination-009"] [data-part="sheet"]{
position-anchor:--vibeui-pagination-009-bar;
position-area:top span-right;margin-bottom:0.5rem;
position-try-fallbacks:flip-block;
}
}
[data-vibeui-block="pagination-009"] [data-part="grid"]{
margin:0;padding:0;list-style:none;
display:grid;grid-template-columns:repeat(auto-fill,minmax(2.5rem,1fr));gap:0.25rem;
}
[data-vibeui-block="pagination-009"] [data-part="num"]{
height:2.5rem;display:grid;place-items:center;border-radius:0.5rem;
text-decoration:none;color:inherit;font-size:0.875rem;font-variant-numeric:tabular-nums;
}
[data-vibeui-block="pagination-009"] [data-part="num"]:hover{background:var(--vibeui-pagination-009-hover)}
[data-vibeui-block="pagination-009"] [data-part="num"]:focus-visible{outline:2px solid var(--vibeui-pagination-009-accent);outline-offset:-2px}
[data-vibeui-block="pagination-009"] [data-part="num"][aria-current="page"]{
background:var(--vibeui-pagination-009-accent);color:oklch(1 0 0);font-weight:650;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="pagination-009"] *{animation:none!important;transition:none!important}}
`

/**
 * Компактная пагинация для телефона: крупные стрелки и сетка номеров.
 * Один файл, ноль зависимостей, собственная палитра, клиентского JS нет.
 */
export function Pagination009({
  page = 3,
  total = 18,
  hrefOf = (value: number) => `?page=${value}`,
  accent,
  className,
  style,
}: Pagination009Props) {
  const palette = {
    ...(accent ? { "--vibeui-pagination-009-accent": accent } : null),
    ...style,
  } as CSSProperties

  const first = page <= 1
  const last = page >= total

  return (
    <>
      <style href="vibeui-pagination-009" precedence="medium">
        {STYLES}
      </style>
      <nav
        data-vibeui-block="pagination-009"
        aria-label="Навигация по страницам"
        className={className}
        style={palette}
      >
        <a
          data-part="step"
          data-dir="prev"
          href={first ? undefined : hrefOf(page - 1)}
          aria-disabled={first || undefined}
          aria-label="Предыдущая страница"
          rel="prev"
        >
          <span data-part="arrow" aria-hidden="true" />
        </a>
        <button
          type="button"
          data-part="current"
          aria-haspopup="true"
          popoverTarget="vibeui-pagination-009-sheet"
        >
          {page}
          <span data-part="of">из {total}</span>
        </button>
        <a
          data-part="step"
          data-dir="next"
          href={last ? undefined : hrefOf(page + 1)}
          aria-disabled={last || undefined}
          aria-label="Следующая страница"
          rel="next"
        >
          <span data-part="arrow" aria-hidden="true" />
        </a>
        <div
          id="vibeui-pagination-009-sheet"
          data-part="sheet"
          popover="auto"
          aria-label="Все страницы"
        >
          <ul data-part="grid">
            {Array.from({ length: total }, (unused, index) => index + 1).map(
              (value) => (
                <li key={value}>
                  <a
                    data-part="num"
                    href={hrefOf(value)}
                    aria-current={value === page ? "page" : undefined}
                  >
                    {value}
                  </a>
                </li>
              ),
            )}
          </ul>
        </div>
      </nav>
    </>
  )
}