Frontile

Pagination

A row of controls for moving through a paged list — page chips, previous/next, and an optional summary of the current range. Reach for it whenever a list is too large to show at once and the user benefits from jumping to a specific page, rather than only scrolling further into an infinite feed.

Import

import { Pagination } from 'frontile';

Usage

@total is the only argument the shortest working control needs: pass the item count and Pagination derives the page count itself, tracks the current page, and needs no @onChange.

import { Pagination } from 'frontile';

<template><Pagination @total={{120}} /></template>

Anatomy

Pagination renders its previous button, page items, and next button automatically. Two optional named blocks let you extend or replace that structure:

Block Purpose
<:summary> Adds a result-range summary using the yielded from, to, and total values.
<:item> Replaces each page item while preserving Pagination's windowing calculations.

Use the default items for button-based navigation. When rendering links through <:item>, provide the appropriate URL and disabled semantics described in Disabled.

Controlled and uncontrolled

The mode is decided by whether @page is passed, not by what it holds — the same rule SegmentedControl uses for @value.

Without @page the control is uncontrolled: it tracks the current page itself, seeded from @defaultPage, and @onChange still fires on every navigation so you can observe the page without owning it.

import { Pagination } from 'frontile';

<template><Pagination @total={{120}} @defaultPage={{3}} /></template>

Passing @page makes it controlled: the rendered page then only ever reflects what you pass, so pair it with @onChange and update your own state. @defaultPage is ignored in this mode.

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { Pagination } from 'frontile';

export default class Example extends Component {
  @tracked page = 1;

  onChange = (page: number): void => {
    this.page = page;
  };

  <template>
    <Pagination
      @total={{120}}
      @page={{this.page}}
      @onChange={{this.onChange}}
    />
  </template>
}

Page count

@total is an item count, not a page count — @pageSize divides it, so totalPages is ceil(total / pageSize). A @total of 45 with the default @pageSize of 10 renders 5 pages.

import { Pagination } from 'frontile';

<template><Pagination @total={{45}} @pageSize={{10}} /></template>

Window size

@siblingCount sets how many page chips show on either side of the current page. The first and last pages are always pinned, and that boundary is not configurable.

The window keeps a constant number of slots as the page moves, so the row never reflows as you page through — a chip near the middle of a long list takes exactly as much space as one at either end.

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { Pagination } from 'frontile';

export default class Example extends Component {
  @tracked page = 10;

  onChange = (page: number): void => {
    this.page = page;
  };

  <template>
    <div class='flex flex-col items-start gap-3'>
      <Pagination
        @total={{200}}
        @siblingCount={{0}}
        @page={{this.page}}
        @onChange={{this.onChange}}
        aria-label='Sibling count 0'
      />
      <Pagination
        @total={{200}}
        @siblingCount={{1}}
        @page={{this.page}}
        @onChange={{this.onChange}}
        aria-label='Sibling count 1'
      />
      <Pagination
        @total={{200}}
        @siblingCount={{2}}
        @page={{this.page}}
        @onChange={{this.onChange}}
        aria-label='Sibling count 2'
      />
    </div>
  </template>
}

Edge controls

@showEdges adds jump-to-first and jump-to-last controls at the ends of the row, for a list long enough that reaching either end by paging through siblings would take a while.

import { Pagination } from 'frontile';

<template>
  <Pagination @total={{500}} @defaultPage={{10}} @showEdges={{true}} />
</template>

Previous and next only

Set @showPages={{false}} for compact page-number navigation with no page chips—just previous and next. The component still uses @total and @pageSize to determine when those controls are disabled.

import { Pagination } from 'frontile';

<template><Pagination @total={{120}} @showPages={{false}} /></template>

Summary

Providing the <:summary> block adds a row reporting the current range, yielding from, to, total, page, and totalPages. It also switches the layout to push the controls to the far edge, since there's now something to justify the row against.

import { Pagination } from 'frontile';

<template>
  <Pagination @total={{120}} @pageSize={{10}} @defaultPage={{2}}>
    <:summary as |s|>
      Showing
      {{s.from}}-{{s.to}}
      of
      {{s.total}}
    </:summary>
  </Pagination>
</template>

The <:item> block is the deep-linking escape hatch: it replaces the page chips with your own markup — a LinkTo, an anchor with a real href — so each page is a navigable URL rather than a button that only calls @onChange. Previous and next remain buttons in this mode; only the page chips are replaced.

The block yields page, isActive, classNames (the same classes the built-in chip would use), and setupItem, a modifier that writes data-active and aria-current="page" onto whatever element you apply it to.

import { Pagination } from 'frontile';

<template>
  <Pagination @total={{50}} @defaultPage={{2}}>
    <:item as |i|>
      <a
        href='/results?page={{i.page}}'
        class={{i.classNames}}
        {{i.setupItem i.isActive}}
      >{{i.page}}</a>
    </:item>
  </Pagination>
</template>

Sizes

import { Pagination } from 'frontile';

<template>
  <div class='flex flex-col items-start gap-3'>
    <Pagination @total={{50}} @size='sm' aria-label='Small' />
    <Pagination @total={{50}} @size='md' aria-label='Medium' />
    <Pagination @total={{50}} @size='lg' aria-label='Large' />
  </div>
</template>

Colors

@color colors the active page chip's fill.

import { Pagination } from 'frontile';

<template>
  <div class='flex flex-col items-start gap-3'>
    <Pagination
      @total={{50}}
      @defaultPage={{2}}
      @color='neutral'
      aria-label='Default intent'
    />
    <Pagination
      @total={{50}}
      @defaultPage={{2}}
      @color='primary'
      aria-label='Primary intent'
    />
    <Pagination
      @total={{50}}
      @defaultPage={{2}}
      @color='secondary'
      aria-label='Secondary intent'
    />
    <Pagination
      @total={{50}}
      @defaultPage={{2}}
      @color='tertiary'
      aria-label='Tertiary intent'
    />
    <Pagination
      @total={{50}}
      @defaultPage={{2}}
      @color='success'
      aria-label='Success intent'
    />
    <Pagination
      @total={{50}}
      @defaultPage={{2}}
      @color='warning'
      aria-label='Warning intent'
    />
    <Pagination
      @total={{50}}
      @defaultPage={{2}}
      @color='danger'
      aria-label='Danger intent'
    />
  </div>
</template>

Disabled

@isDisabled disables every control in the row.

For the component's own page chips and prev/next buttons, this sets a real disabled attribute, so those controls are inert. An element you supply through <:item> — typically an <a href> — has no disabled attribute to set, so only the pointer is blocked; the element stays keyboard-reachable and Enter-activatable. If you render your own links, handle the disabled state on them too, for example by dropping the href, the same way TabNav disables an anchor.

import { Pagination } from 'frontile';

<template>
  <Pagination @total={{50}} @defaultPage={{3}} @isDisabled={{true}} />
</template>

Accessibility

Pagination renders a <nav> landmark, named by @label (defaulting to 'pagination') — set it explicitly when a page has more than one pagination control. The active page chip carries aria-current="page"; every other chip carries none.

The ellipsis marking a gap in the page row is aria-hidden, with a visually hidden "More pages" label so screen readers still get a name for it.

Every control — previous, next, the edge jumps, and each page chip — stays individually reachable by Tab, with no roving tabindex. These are navigation targets rather than a composite widget, so each one is its own stop rather than being grouped under a single tab-managed focus point.

API

Pagination

Element: HTMLElement

A row of controls for moving through a paged list.

It deliberately does not use rovingFocus: these are navigation targets, not a composite widget, so every control stays individually reachable by Tab. The ARIA tabs pattern does not apply, and applying it would remove destinations from the tab order for no gain -- the same reasoning TabNav documents.

Arguments

Name Type Default Description
classes SlotsToClasses<'base' | 'summary' | 'item' | 'list' | 'page' | 'prev' | 'next' | 'ellipsis'> - Class names for each slot of the component, merged with the theme's.
color enum 'neutral' The colour of the active page chip.
defaultPage number 1 The page to start on when uncontrolled. Ignored in controlled mode.
isDisabled boolean false Disables every control.
label string 'pagination' Accessible name for the nav landmark. Worth setting when a page has more than one pagination on it.
onChange function - Called with the new page on every navigation, in both modes. Never called with an out-of-range page, and never called when the page would not change.
page number -

The current page, 1-based.

Passing this argument at all puts the component in controlled mode: the rendered page then only ever reflects what you pass, so pair it with @onChange and update your own state. Omit it entirely to let the component track the page itself, seeded by @defaultPage.

pageSize number 10 Items shown per page. Values below 1 are treated as 1.
showEdges boolean false Adds jump-to-first and jump-to-last controls at the ends of the row.
showPages boolean true Set to false for compact page-number navigation with previous and next controls but no page chips. @total and @pageSize still determine the first and last pages.
siblingCount number 1 How many page chips to show either side of the current one. The first and last pages are always shown; that boundary is not configurable.
size enum 'md'
total number 0 Total number of items across all pages -- an item count, not a page count. @pageSize divides it. This is what lets the summary block be handed a real item range instead of making the caller compute one.

Blocks

Name Type Default Description
summary Array -
item Array -
Released under MIT License - Created by Josemar Luedke