Przejdź do treści

Pagination

Dzieli długi zbiór wyników na strony i pozwala się między nimi poruszać — przewidywalnie, z linkami, które można udostępnić, i z poczuciem, jak duży jest cały zbiór.

Kod źródłowyRejestr
import {
  Pagination,
  PaginationContent,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
} from "@/components/ui/pagination"

export default function PaginationDemo() {
  return (
    <Pagination>
      <PaginationContent>
        <PaginationItem>
          <PaginationPrevious href="?page=1" />
        </PaginationItem>
        <PaginationItem>
          <PaginationLink href="?page=1">1</PaginationLink>
        </PaginationItem>
        <PaginationItem>
          <PaginationLink href="?page=2" isActive>
            2
          </PaginationLink>
        </PaginationItem>
        <PaginationItem>
          <PaginationLink href="?page=3">3</PaginationLink>
        </PaginationItem>
        <PaginationItem>
          <PaginationEllipsis />
        </PaginationItem>
        <PaginationItem>
          <PaginationLink href="?page=12">12</PaginationLink>
        </PaginationItem>
        <PaginationItem>
          <PaginationNext href="?page=3" />
        </PaginationItem>
      </PaginationContent>
    </Pagination>
  )
}

Anatomia

  1. 1PoprzedniaCofa o jedną stronę. Na małych ekranach zwija się do samej strzałki.
  2. 2Link do stronyPrawdziwy link do strony — najlepiej z numerem strony w adresie URL.
  3. 3Bieżąca stronaWyróżniona obrysem i odczytywana jako bieżąca strona.
  4. 4WielokropekZastępuje pominięte strony.
  5. 5NastępnaPrzechodzi o jedną stronę dalej.

Instalacja

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/pagination.json

Użycie

import {
  Pagination,
  PaginationContent,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
} from "@/components/ui/pagination"
<Pagination>
  <PaginationContent>
    <PaginationItem>
      <PaginationPrevious href="?page=1" />
    </PaginationItem>
    <PaginationItem>
      <PaginationLink href="?page=2" isActive>
        2
      </PaginationLink>
    </PaginationItem>
    <PaginationItem>
      <PaginationNext href="?page=3" />
    </PaginationItem>
  </PaginationContent>
</Pagination>

Strony to prawdziwe linki. Umieść numer strony w adresie URL (?page=3), aby wyniki dało się udostępnić, dodać do zakładek, przywrócić przyciskiem Wstecz i zaindeksować w wyszukiwarkach.

Przykłady

Domyślna

Bieżąca strona dostaje isActive, który przełącza ją na styl z obrysem i ustawia aria-current="page". Poniżej breakpointu sm linki Poprzednia i Następna zwijają się do samych strzałek.

import {
  Pagination,
  PaginationContent,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
} from "@/components/ui/pagination"

export default function PaginationDemo() {
  return (
    <Pagination>
      <PaginationContent>
        <PaginationItem>
          <PaginationPrevious href="?page=1" />
        </PaginationItem>
        <PaginationItem>
          <PaginationLink href="?page=1">1</PaginationLink>
        </PaginationItem>
        <PaginationItem>
          <PaginationLink href="?page=2" isActive>
            2
          </PaginationLink>
        </PaginationItem>
        <PaginationItem>
          <PaginationLink href="?page=3">3</PaginationLink>
        </PaginationItem>
        <PaginationItem>
          <PaginationEllipsis />
        </PaginationItem>
        <PaginationItem>
          <PaginationLink href="?page=12">12</PaginationLink>
        </PaginationItem>
        <PaginationItem>
          <PaginationNext href="?page=3" />
        </PaginationItem>
      </PaginationContent>
    </Pagination>
  )
}

Kontrolowana z zakresem stron

Gdy stron jest dużo, pokaż pierwszą, ostatnią i bieżącą wraz z sąsiednimi; luki zastąp komponentem PaginationEllipsis.

Strona 7 z 20

"use client"

import * as React from "react"

import {
  Pagination,
  PaginationContent,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
} from "@/components/ui/pagination"

/** First, last, the current page and its neighbours — gaps become ellipses. */
function pageRange(current: number, total: number): (number | "gap")[] {
  const pages = new Set([1, total, current - 1, current, current + 1])
  const sorted = [...pages]
    .filter((p) => p >= 1 && p <= total)
    .sort((a, b) => a - b)
  return sorted.flatMap((page, i) =>
    i > 0 && page - sorted[i - 1] > 1 ? (["gap", page] as const) : [page]
  )
}

export default function PaginationControlled() {
  const total = 20
  const [page, setPage] = React.useState(7)

  function go(event: React.MouseEvent, next: number) {
    event.preventDefault()
    setPage(Math.min(total, Math.max(1, next)))
  }

  return (
    <div className="flex flex-col items-center gap-4">
      <Pagination>
        <PaginationContent>
          <PaginationItem>
            <PaginationPrevious
              href={`?page=${page - 1}`}
              aria-disabled={page === 1}
              onClick={(event) => go(event, page - 1)}
            />
          </PaginationItem>
          {pageRange(page, total).map((item, i) =>
            item === "gap" ? (
              <PaginationItem key={`gap-${i}`}>
                <PaginationEllipsis />
              </PaginationItem>
            ) : (
              <PaginationItem key={item}>
                <PaginationLink
                  href={`?page=${item}`}
                  isActive={item === page}
                  onClick={(event) => go(event, item)}
                >
                  {item}
                </PaginationLink>
              </PaginationItem>
            )
          )}
          <PaginationItem>
            <PaginationNext
              href={`?page=${page + 1}`}
              aria-disabled={page === total}
              onClick={(event) => go(event, page + 1)}
            />
          </PaginationItem>
        </PaginationContent>
      </Pagination>
      <p className="text-sm text-muted-foreground tabular-nums">
        {`Strona ${page} z ${total}`}
      </p>
    </div>
  )
}

Kompaktowa

Gdy brakuje miejsca albo konkretna strona jest mniej ważna niż kierunek, pokaż pozycję jako tekst między dwoma linkami z ikonami. Każdemu z tych linków nadaj aria-label.

import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"

import {
  Pagination,
  PaginationContent,
  PaginationItem,
  PaginationLink,
} from "@/components/ui/pagination"

export default function PaginationCompact() {
  return (
    <Pagination>
      <PaginationContent className="gap-3">
        <PaginationItem>
          <PaginationLink
            href="?page=2"
            size="icon-sm"
            aria-label="Przejdź do poprzedniej strony"
          >
            <ChevronLeftIcon />
          </PaginationLink>
        </PaginationItem>
        <PaginationItem className="text-sm text-muted-foreground tabular-nums">
          Strona <span className="font-medium text-foreground">3</span> z 12
        </PaginationItem>
        <PaginationItem>
          <PaginationLink
            href="?page=4"
            size="icon-sm"
            aria-label="Przejdź do następnej strony"
          >
            <ChevronRightIcon />
          </PaginationLink>
        </PaginationItem>
      </PaginationContent>
    </Pagination>
  )
}

W tabelach danych paginacji towarzyszy wybór liczby wierszy na stronie i podsumowanie zakresu (26–50 z 237). Po zmianie tej liczby wróć do pierwszej strony.

Wierszy na stronę
"use client"

import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import {
  Pagination,
  PaginationContent,
  PaginationItem,
} from "@/components/ui/pagination"
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

const sizes = [
  { label: "10", value: "10" },
  { label: "25", value: "25" },
  { label: "50", value: "50" },
]

export default function PaginationTableFooter() {
  const total = 237
  const [size, setSize] = React.useState("25")
  const [page, setPage] = React.useState(1)
  const perPage = Number(size)
  const pages = Math.ceil(total / perPage)
  const from = (page - 1) * perPage + 1
  const to = Math.min(total, page * perPage)

  return (
    <div className="flex w-full max-w-xl flex-wrap items-center justify-between gap-4 rounded-xl border bg-card px-4 py-3 text-sm shadow-raised">
      <div className="flex items-center gap-2 text-muted-foreground">
        <span id="rows-per-page">Wierszy na stronę</span>
        <Select
          items={sizes}
          value={size}
          onValueChange={(value) => {
            setSize(value as string)
            setPage(1)
          }}
        >
          <SelectTrigger
            size="sm"
            className="w-18"
            aria-labelledby="rows-per-page"
          >
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectGroup>
              {sizes.map((option) => (
                <SelectItem key={option.value} value={option.value}>
                  {option.label}
                </SelectItem>
              ))}
            </SelectGroup>
          </SelectContent>
        </Select>
      </div>
      <Pagination className="mx-0 w-auto">
        <PaginationContent className="gap-3">
          <PaginationItem className="text-muted-foreground tabular-nums">
            {`${from}–${to} z ${total}`}
          </PaginationItem>
          <PaginationItem className="flex gap-1">
            <Button
              variant="outline"
              size="icon-sm"
              aria-label="Poprzednia strona"
              disabled={page === 1}
              onClick={() => setPage(page - 1)}
            >
              <ChevronLeftIcon />
            </Button>
            <Button
              variant="outline"
              size="icon-sm"
              aria-label="Następna strona"
              disabled={page === pages}
              onClick={() => setPage(page + 1)}
            >
              <ChevronRightIcon />
            </Button>
          </PaginationItem>
        </PaginationContent>
      </Pagination>
    </div>
  )
}

Wytyczne

Kiedy używać

  • Dla zbiorów wyników, które się przeszukuje i filtruje, i do których się wraca: tabel, list w panelach administracyjnych, wyników wyszukiwania, archiwów.
  • Gdy liczy się pozycja w zbiorze albo trzeba dotrzeć do końca bez wczytywania wszystkiego, co jest przed nim.

Kiedy nie używać

  • Dla strumieni treści, które się przegląda, a nie przeszukuje — lepiej sprawdzi się nieskończone przewijanie lub przycisk Wczytaj więcej.
  • Gdy elementów jest mniej niż około 25 — pokaż wszystkie na jednej stronie.
  • Dla kroków procesu — użyj wskaźnika kroków; strony sugerują równorzędne, niezależne od siebie części.

Paginacja, „Wczytaj więcej” czy nieskończone przewijanie

WzorzecWybierz, gdy
PaginacjaUżytkownicy porównują konkretne wyniki, wracają do nich lub je udostępniają; stopka musi pozostać osiągalna; zbiór jest duży i skończony.
„Wczytaj więcej”Przeglądanie jest swobodne, ale użytkownicy nadal potrzebują stopki i kontroli nad tym, ile treści się wczytuje.
Nieskończone przewijanieTreść to strumień bez wyraźnego końca (jak w mediach społecznościowych). Unikaj go wszędzie tam, gdzie użytkownik ma coś do załatwienia.

Zachowanie

Trzymaj kontrolkę w tym samym miejscu na każdej stronie — pod wynikami, a przy długich listach opcjonalnie także nad nimi. Po zmianie strony przewiń do początku wyników i zachowaj filtry oraz sortowanie. Pokaż łączną liczbę wyników, jeśli ją znasz — ułatwia decyzję, czy zawęzić wyszukiwanie.

26–50 z 237
DobrzeStabilna kontrolka z oznaczoną bieżącą pozycją i jasno podaną liczbą wszystkich wyników.
ŹleDziesiątki linków do stron przytłaczają i zawijają się do kolejnych wierszy; nic nie wskazuje, gdzie jesteś.

Dostępność

  • Punkt orientacyjny. Pagination renderuje <nav> z etykietą Paginacja — w języku ustawionym przez LocaleProvider. Jeśli na stronie są dwie paginacje (nad wynikami i pod nimi), nadaj im różne etykiety, np. Paginacja, góra i Paginacja, dół.
  • Bieżąca strona. isActive ustawia aria-current="page", więc bieżąca strona jest odczytywana przez czytniki ekranu, a nie tylko wyróżniona wizualnie.
  • Linki kierunkowe mają etykiety Poprzednia strona i Następna strona — także wtedy, gdy widać tylko strzałki.
  • Wielokropki ukrywają swoją ikonę przed technologiami wspomagającymi, a zamiast niej odczytywany jest tekst Więcej stron — w języku ustawionym przez LocaleProvider.
  • Wyłączone skrajne linki. Na pierwszej i ostatniej stronie ustaw aria-disabled na linku Poprzednia lub Następna i zostaw go na miejscu, aby układ nie przeskakiwał.
KlawiszDziałanie
Tab
Przenosi fokus kolejno po linkach do stron.
Enter
Otwiera link z fokusem.

Dokumentacja API

Pagination

Punkt orientacyjny <nav>. Przyjmuje wszystkie propsy elementu <nav>.

PaginationContent i PaginationItem

PaginationContent to element <ul>, który rozmieszcza pozycje; PaginationItem to każdy pojedynczy <li>.

Renderuje element <a> ostylowany jak Button z prfct.

PropTypDomyślnie
href

Adres strony — najlepiej URL z numerem strony.

stringBrak wartości domyślnej
isActive

Oznacza bieżącą stronę: styl z obrysem i aria-current="page".

booleanfalse
size

Rozmiar przycisku; numery stron używają kwadratowych rozmiarów przeznaczonych dla przycisków z ikoną.

"xs" | "sm" | "default" | "lg" | "xl" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | "icon-xl""icon"

PaginationPrevious i PaginationNext

PropTypDomyślnie
href

Adres poprzedniej lub następnej strony.

stringBrak wartości domyślnej
text

Widoczna etykieta, wyświetlana od breakpointu sm wzwyż. Domyślnie komunikaty bieżącego języka.

stringmessages.previous | messages.next

PaginationEllipsis

Renderuje <span> z ikoną ⋯, która zastępuje pominięte strony. Ikona jest ukryta przed technologiami wspomagającymi; zamiast niej odczytywany jest wizualnie ukryty tekst Więcej stron.