Pagination

Paginationは、コンテンツを複数のページに分割し、ユーザーが異なるページへ移動できるようにするコンポーネントです。

プロパティ

Paginationは、以下のプロパティをサポートしています。

名前 デフォルト値 説明
maxPage number 0 最大ページ数を指定します。
maxVisible number 7 表示する最大ページ項目数(省略記号含む)。3以下のときは省略記号なしで表示されます。
currentPage number 現在のページ番号です。
prevLabel string 前へ 「前へ」ボタンの表示ラベルです。
nextLabel string 次へ 「次へ」ボタンの表示ラベルです。
showEllipsis boolean true 省略記号を表示するかどうかを制御します。
disabled boolean false 指定された場合は選択不可になります。
onChangePage (page: number) => void ページ番号がクリックされた際に呼び出される関数です。

インストールの手順

以下のコンポーネントのコードを、使いたいプロジェクトにコピー&ペーストします。
パスは実際のプロジェクトの構成にあわせて更新します。

atoms/Pagination.svelte
        <!--
@component
## 概要
- ページネーション UI コンポーネントです。
- 前へ・次へボタン、ページ番号、省略記号(…)を含む表示が可能です。
- 表示する最大ページ数、現在のページ番号、省略記号の表示非表示は外部から制御できます。

## 機能
- 最大ページ数・現在のページを元に、最適なページリンクを自動生成します。
- 中央のページ番号を基準に、常に maxVisible 件に収まるよう調整されます。
- …(省略記号)は maxVisible に含まれ、表示項目数にカウントされます。
- maxVisible <= 3 の場合は省略記号を使わず、現在のページ中心に単純な連番のみを表示します。
- …(省略記号)はクリック不可。ページリンクとしての役割を持ちません。
- 「前へ」「次へ」ナビゲーション付き。
- ページ選択時にはonChangePageが呼び出され、親コンポーネントで状態管理が可能です。

## Props
- maxPage: 最大ページ数を指定します。
- maxVisible: 表示する最大ページ項目数(省略記号含む)。初期値は7。
- currentPage: 現在のページ番号です。
- class: 外部からスタイルを追加できます。
- prevLabel: 「前へ」ボタンのラベル。デフォルトは「前へ」。
- nextLabel: 「次へ」ボタンのラベル。デフォルトは「次へ」。
- showEllipsis: 省略記号の表示位置を設定できます。 none | full。初期値はfull
- disabled: 無効化することができます。デフォルトはfalse
- onChangePage: ページ番号がクリックされたときに呼ばれるコールバック関数です。引数は選択されたページ番号。

## Usage
```svelte
  <Pagination max={10} maxVisible={4} {currentPage} {onChangePage} />
```
-->

<script module lang="ts">
  import type { ClassValue } from 'svelte/elements';
  import { cva, type VariantProps } from 'class-variance-authority';

  const MIN_ELLIPSIS_THRESHOLD = 3;
  const MAX_VISIBLE = 7;

  export const paginationItemVariants = cva('flex items-center justify-center shrink-0 size-10 rounded-md font-medium leading-none text-base-foreground-default text-sm outline-primary transition-colors hover:bg-base-container-accent/90 focus-visible:outline-[0.125rem] focus-visible:outline-offset-[0.125rem] focus-visible:outline-primary', {
    variants: {
      /** type属性 */
      type: {
        previous: ['w-auto pr-4 pl-2.5 cursor-pointer'],
        next: ['w-auto pr-2.5 pl-4 cursor-pointer'],
        link: ['cursor-pointer'],
        ellipsis: ['pointer-events-none'],
      },
      /** 操作できるかどうか */
      disabled: {
        true: ['opacity-50 pointer-events-none'],
        false: [],
      },
      /** 現在のページかどうか */
      current: {
        true: ['border border-primary ease-in-out'],
        false: ['border border-transparent'],
      },
    },
    defaultVariants: {
      disabled: false,
    },
  });

  export type PaginationVariants = VariantProps<typeof paginationItemVariants>;
  export interface PaginationProps extends PaginationVariants {
    /** 最大ページ数 */
    maxPage?: number;
    /** 最大表示ページ数 */
    maxVisible?: number;
    /** 現在のページ番号 */
    currentPage?: number;
    /** クラス */
    class?: ClassValue;
    /** 前へボタンのラベル */
    prevLabel?: string;
    /** 次へボタンのラベル */
    nextLabel?: string;
    /** 省略記号の表示位置 */
    showEllipsis?: boolean;
    /** ページを全て無効にするかどうか */
    disabled?: boolean;
    /** ページが変更されたときに呼ばれる関数 */
    onChangePage?: (page: number) => void;
  }
</script>

<script lang="ts">
  import { ChevronLeft, ChevronRight, Ellipsis } from '@lucide/svelte';

  let { class: className, maxPage = 0, maxVisible = MAX_VISIBLE, currentPage = 1, prevLabel = '前へ', nextLabel = '次へ', showEllipsis = true, disabled = false, onChangePage }: PaginationProps = $props();

  let pages = $state<(number | 'ellipsis')[]>([]);

  $effect(() => {
    const last = maxPage;
    let dynamic = maxVisible;

    // 調整して収まる範囲を探す
    while (dynamic >= 1) {
      const mid = calculateStartEnd(currentPage, dynamic, last);
      const trial = buildPageRange(mid.start, mid.end, last, showEllipsis, maxVisible, currentPage);
      if (trial.length <= maxVisible) {
        pages = trial;
        return;
      }
      dynamic--;
    }

    pages = buildPageRange(currentPage, currentPage, last, showEllipsis, maxVisible, currentPage);
  });

  // 現在ページと可変数から、中央ページ範囲の start と end を計算
  function calculateStartEnd(current: number, visible: number, last: number): { start: number; end: number } {
    let half = Math.floor(visible / 2);
    let start = current - half;
    let end = current + half;

    // maxVisible が偶数のとき、右に1件多くする
    if (visible % 2 === 0) {
      start += 1;
    }

    // 範囲を補正
    if (start < 1) {
      end += 1 - start;
      start = 1;
    }
    if (end > last) {
      start -= end - last;
      end = last;
    }

    // maxVisible 件になるようさらに補正
    const count = end - start + 1;
    if (count < visible) {
      if (start === 1) {
        end = Math.min(last, end + (visible - count));
      }
      else if (end === last) {
        start = Math.max(1, start - (visible - count));
      }
    }
    return { start, end };
  }

  // 省略記号を含めて最終的なページ番号配列を構築
  function buildPageRange(
    start: number,
    end: number,
    last: number,
    showEllipsis = true,
    maxVisible: number,
    currentPage: number,
  ): (number | 'ellipsis')[] {
    const result: (number | 'ellipsis')[] = [];

    // maxVisible <= 3 のときは中央寄せの単純連番のみ
    if (maxVisible <= MIN_ELLIPSIS_THRESHOLD) {
      let half = Math.floor(maxVisible / 2);
      let simple_start = currentPage - half;
      let simple_end = currentPage + half;

      if (maxVisible % 2 === 0) simple_start += 1;

      if (simple_start < 1) {
        simple_end += 1 - simple_start;
        simple_start = 1;
      }
      if (simple_end > last) {
        simple_start -= simple_end - last;
        simple_end = last;
      }

      simple_start = Math.max(1, simple_start);
      simple_end = Math.min(last, simple_end);
      return Array.from({ length: simple_end - simple_start + 1 }, (_, i) => simple_start + i);
    }

    // showEllipsis = false かつ maxVisible >= 4 のときは常に最初と最後を固定表示
    if (!showEllipsis && maxVisible >= 4) {
      const inner_count = maxVisible - 2;
      const half = Math.floor(inner_count / 2);
      let inner_start = currentPage - half;
      let inner_end = currentPage + half;

      if (inner_count % 2 === 0) inner_start += 1;

      if (inner_start < 2) {
        inner_end += 2 - inner_start;
        inner_start = 2;
      }
      if (inner_end > last - 1) {
        inner_start -= inner_end - (last - 1);
        inner_end = last - 1;
      }

      inner_start = Math.max(inner_start, 2);
      inner_end = Math.min(inner_end, last - 1);

      result.push(1);
      for (let i = inner_start; i <= inner_end; i++) {
        result.push(i);
      }
      result.push(last);
      return result;
    }

    // 通常のellipsisロジック
    if (start > 3) {
      result.push(1, 'ellipsis');
    }
    else {
      for (let i = 1; i < start; i++) result.push(i);
    }

    for (let i = start; i <= end; i++) {
      result.push(i);
    }

    if (end < last - 1) {
      result.push('ellipsis', last);
    }
    else {
      for (let i = end + 1; i <= last; i++) result.push(i);
    }
    return result;
  }

  function findPrevEnabledPage(current: number, disabled: boolean): number | null {
    if (disabled) return null;
    return current > 1 ? current - 1 : null;
  }

  function findNextEnabledPage(current: number, max: number, disabled: boolean): number | null {
    if (disabled) return null;
    return current < max ? current + 1 : null;
  }
</script>

<div class={[className, 'flex items-center justify-center gap-0.5']}>
  <button
    class={paginationItemVariants({ type: 'previous', disabled: !findPrevEnabledPage(currentPage, disabled) })}
    onclick={() => {
      const prev = findPrevEnabledPage(currentPage, disabled);
      if (prev) onChangePage?.(prev);
    }}
    disabled={!findPrevEnabledPage(currentPage, disabled)}
  >
    <div class="flex items-center justify-center shrink-0 gap-1"><ChevronLeft size="1rem" />{prevLabel}</div>
  </button>

  {#each pages as page}
    {#if page === 'ellipsis'}
      <span class={paginationItemVariants({ type: 'ellipsis', disabled })}>
        <Ellipsis size="1rem" />
      </span>
    {:else}
      {#key page}
        <button
          class={paginationItemVariants({ type: 'link', current: page === currentPage && !disabled, disabled })}
          onclick={() => {
            if (!disabled) onChangePage?.(page);
          }}
          {disabled}
          aria-current={page === currentPage && !disabled ? 'page' : undefined}
        >
          {page}
        </button>
      {/key}
    {/if}
  {/each}

  <button
    class={paginationItemVariants({ type: 'next', disabled: !findNextEnabledPage(currentPage, maxPage, disabled) })}
    onclick={() => {
      const next = findNextEnabledPage(currentPage, maxPage, disabled);
      if (next) onChangePage?.(next);
    }}
    disabled={!findNextEnabledPage(currentPage, maxPage, disabled)}
  >
    <div class="flex items-center justify-center shrink-0 gap-1"> {nextLabel} <ChevronRight size="1rem" /></div>
  </button>
</div>

      

使い方


サンプル

Default

基本的なページネーションのスタイルです。前後ボタンとページ番号を表示します。

Disabled

選択不可の状態です。

OnChangePage

ページ番号をクリックすると onChangePage が発火し、選択されたページ番号を親コンポーネントに通知します。
これにより、外部でページ状態を管理できます。