<!-- GENERATED by scripts/build-llms.mjs from llms/data.md — do not edit this file. -->

# `lr-pagination`

- **Import** `import '@aceshooting/lyra-ui/components/lr-pagination.js';` (stable tag alias; registers the tag)
- **Class** `LyraPagination`, also available unregistered from `@aceshooting/lyra-ui/components/data/pagination/pagination.class.js`
- **Family** `components/data/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.0.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
- **Deprecations** none
- **Optional peers** none
- **Themeable via** 22 parts, 22 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-pagination`

Controlled page navigation for server-side or client-side data sets: a numbered page list with
elided runs, previous/next and optional first/last buttons, an opt-in localized item-range summary,
a compact layout that swaps the list for a validated numeric page jump, and a polite announcement
after the host applies a requested page. The component owns no data fetching and never mutates
`page`.

**Indeterminate mode (`total="-1"`):** for a server API that never returns a total — limit/offset and
cursor/keyset APIs typically don't — set `total="-1"` and use `hasNext` to report whether one more
page exists. The component then renders previous/next plus a page-number field only: no numbered
page list, no item-range summary, and no `/ totalPages` readout, regardless of `format`,
`withSummary`, or `withEdges`. Previous is disabled at page 1 exactly as in the known-total path;
next is disabled once `hasNext` is `false`. Events, focus management, and the applied-page
announcement all use the same contract as known-total pagination — `lr-before-page-change`/
`lr-page-change`/`lr-activate` fire the same way, and focus still follows the applied page — except
the announcement text has no total-pages figure (`"Page {page}"` instead of `"Page {page} of
{totalPages}"`). Any negative `total` other than exactly `-1` still renders the ordinary empty
state.

**9.0.0 migration:** remove reads of `pageCount` and use the required mirrored `totalPages` getter.
There is no alias or compatibility shim; keeping both names made one derived total look like two
independent concepts.

**8.0.0 migration — these changes are breaking:**

- `total-items` is now `total` (property `totalItems` → `total`). The old attribute no longer binds
  to anything: a pager left on `total-items` keeps `total` at its `0` default and silently renders
  the empty state with every control disabled.
- `hide-summary` is now `with-summary`, which **inverts the default**. The summary used to render
  unless you opted out; it is now hidden unless you opt in. Drop `hide-summary` wherever it appears,
  and add `with-summary` to every pager that was relying on the old show-by-default behavior.
- `pageSize` now defaults to `10` instead of `20`, matching the mirrored pagination contract. Keep
  `page-size="20"` explicitly wherever the old twenty-item window is part of the data request.
- `lr-page-change.detail` now includes `pageSize` as well as `page`, and the new cancelable
  `lr-before-page-change` fires first. Existing readers of `detail.page` keep working; code that
  asserted the exact one-key detail object must accept `{ page, pageSize }`.

**Properties and getters:**

- `page: number = 1` (reflected) — the currently applied page. Runtime values are presented within
  the valid `1..totalPages` range, but the public property itself remains controlled and is not
  rewritten by the component
- `pageSize: number = 10` (attribute `page-size`) — items per page; finite values are truncated to
  a non-negative integer for the derived calculations, and zero produces no pages
- `total: number = 0` (attribute `total`) — total item count; finite values are truncated
  to a non-negative integer for display and page-count calculations, except for the exact sentinel
  `-1`, which enters indeterminate mode (see below) instead of clamping to the ordinary empty state.
  Any other negative value — including one arrived at by a miscalculation — still clamps to `0` and
  renders the empty state, unchanged
- `totalPages: number` (readonly getter) — `ceil(total / pageSize)` after the normalization above,
  or `0` when either normalized input is zero (always `0` in indeterminate mode too, since there is
  no total to derive a page count from)
- `hasNext: boolean = true` (attribute `has-next`, reflected) — whether at least one more page
  exists past the current one. Consulted only in indeterminate mode; previous availability is always
  derivable from `page` alone
- `disabled: boolean = false` (reflected)
- `loading: boolean = false` (reflected) — disables all controls and sets `aria-busy="true"` on the
  internal navigation landmark
- `withSummary: boolean = false` (attribute `with-summary`, reflected) — renders the built-in range
  summary while retaining the controls. Opt-in since 8.0.0, when the old `hide-summary` attribute
  (shown-by-default) was renamed to `with-summary` (hidden-by-default) — the **8.0.0 migration**
  bullet above has the full inverted-default detail
- `size: '2xs'|'xs'|'s'|'m'|'l'|'xl' = 'm'` (reflected) — control footprint, on the library's shared
  six-step ladder: `--lr-pagination-control-size` and `--lr-pagination-font-size` read the same
  `--lr-form-control-height`/`--lr-form-control-font-size` knobs `lr-button`/`lr-input`/`lr-select`
  sit on, so a pager in a toolbar row lines up with its neighbours at every tier.
  `'small'`/`'medium'`/`'large'` are accepted as exact synonyms of `'s'`/`'m'`/`'l'` — no attribute
  rewrite when migrating from an upstream that spells them that way
- `format: 'standard'|'compact' = 'standard'` (reflected) — `standard` renders the numbered page
  list; `compact` replaces it with the single page-jump input and a `/ totalPages` readout, for a
  toolbar or card footer. Previous/next and the `with-edges` buttons render in both. Foreign
  runtime values normalize to `standard`
- `siblingCount: number = 2` (attribute `sibling-count`) — pages shown either side of the current
  page in the numbered list. Read as a non-negative integer clamped to `0..25`; non-finite input
  falls back to `2`
- `boundaryCount: number = 1` (attribute `boundary-count`) — pages always pinned at the start and
  the end of the numbered list. Same normalization, fallback `1`
- `withEdges: boolean = false` (attribute `with-edges`, reflected) — adds first-page and last-page
  buttons outside previous/next, each drawn with a doubled chevron
- `withoutNav: boolean = false` (attribute `without-nav`, reflected) — omits previous/next while
  retaining numbered pages and any `with-edges` first/last controls
- `hideSinglePage: boolean = false` (attribute `hide-single-page`, reflected) — renders nothing when
  the normalized data set has zero or one page
- `hrefTemplate: string | ((page: number) => string) = ''` (attribute `href-template`) — renders
  every navigation target outside the compact field as an `<a>` instead of a `<button>`, so the
  pager works before hydration and for crawlers. Standard layout includes numbered pages and
  interactive ellipses; compact layout retains link-mode previous/next and optional first/last
  controls around its controlled page field. A string uses `{page}` as the placeholder
  (`/products?page={page}`); a function receives the page number and returns the URL and can only be
  assigned from JavaScript
- `appearance: 'accent'|'filled'|'outlined'|'filled-outlined'|'plain' = 'outlined'` (reflected) —
  the resting fill and border of the first/previous/next/last buttons and the numbered pages (not the
  compact page-jump input), applied through the two custom properties below. The applied page stays a
  solid brand chip in all five, so the appearance never decides whether the current page is
  identifiable
- `itemLabel: string = ''` (attribute `item-label`) — custom item noun used in the summary; empty
  selects the localized singular `item` or plural `items` key
- `accessibleLabel: string | null = null` (attribute `aria-label`) — host accessible-name override
  forwarded to the internal `<nav>` landmark; takes precedence over `label`
- `label?: string` — explicit fallback accessible name for the internal `<nav>` landmark, applied
  when no host `aria-label` is set; omitting it reads back `undefined` and localizes the
  `paginationLabel` message, while an explicit empty string renders no visible/accessible label
- `pageLabel?: string` (attribute `page-label`) — optional accessible-name override for the page-jump input
- `previousLabel?: string` (attribute `previous-label`), `nextLabel?: string`
  (attribute `next-label`) — optional accessible-name overrides for the icon-only directional buttons
- `firstLabel?: string` (attribute `first-label`), `lastLabel?: string`
  (attribute `last-label`) —
  optional accessible-name overrides for the icon-only edge buttons rendered by
  `with-edges`

Omitting any of those five control-label properties resolves the matching locale message. Any
supplied string is an explicit per-instance override and renders verbatim, including the built-in
English wording under a non-English `.strings` catalog and an empty string.

**Events:** `lr-before-page-change` (frozen readonly `detail: { page: number, pageSize: number }`, bubbles and
composes, cancelable) fires first for any valid, different requested page. Preventing it suppresses
the request and restores the compact field to the controlled page. If accepted,
`lr-page-change` follows with the same frozen detail (bubbles and composes, non-cancelable), from a numbered
page, interactive ellipsis, previous/next, first/last, or the compact page-jump input. The host
applies `event.detail.page` back to `page` after routing, fetching, or any other policy decision.
When that requested value is applied, focus follows the new `[part="page-current"]` control; the
compact layout returns focus to its page field instead. This keeps keyboard orientation intact even
when a next/previous, edge, or ellipsis control is replaced by the newly rendered page window. If
the application applies the controlled page asynchronously and the user has moved focus outside
the pagination component in the meantime, it leaves that newer focus destination alone.
`lr-activate` (`detail: { value: number }`, bubbles and composes, non-cancelable) fires on **every**
accepted page request, whether or not the page actually moved; `value` is the requested page number.
`lr-before-page-change` remains the veto point, and a vetoed request emits no activation at all, so
`lr-activate` only ever reports a request the component accepted. Use it for the re-request of the
current page that `lr-page-change` deliberately stays silent for — "load that page again" is a real
intent, and it is otherwise unobservable, because the page buttons and the jump input live in this
shadow root, so a retargeted `click` names no page and pressing Enter on the jump field produces no
click at all. When a request _does_ move the page, `lr-before-page-change` and `lr-page-change` are
emitted first. Link-mode anchors navigate without emitting it.
`focus` and `blur` are re-dispatched as exactly one bubbling, composed native `FocusEvent` from
whichever internal control the user reached — a page button or link, previous/next, first/last, or
the page input. The shadow-origin event is stopped, and the host event preserves its native focus
payload, including `relatedTarget`.

**Methods:** `focus(options?)`, `blur()` and `click()` resolve the primary control for the active
format at call time: the applied `[part="page-current"]` button/link in `standard`, or the
`[part="page-input"]` page-jump input in `compact`. `click()` additionally does nothing while the
controls are disabled.

**Slots:** `first-icon`, `previous-icon`, `next-icon`, and `last-icon` replace the corresponding
directional glyph while leaving its localized accessible name on the owning control.

**CSS parts:** `base` and `pagination` are aliases on the same navigation wrapper; `summary`,
`controls`, `pages`, `button`, `page`, `page-current`, `ellipsis`,
`first-button`, `first-icon`, `previous-button`, `previous-icon`, `page-field`, `label`, `page-input`,
`page-count`, `next-button`, `next-icon`, `last-button`, `last-icon`, `live-region`.

`pages` is the `role="list"` wrapper, `page` one numbered control inside it (a `<button>`, or an
`<a>` under `href-template`), and `ellipsis` an accessible interactive control that jumps several
pages into its skipped run. `button` is shared by every page, ellipsis, and navigation control;
`label` aliases the compact `page-field` wrapper. The applied page's control carries a second part token, so
`::part(page-current)` selects it and `::part(page)` still selects every page including the current
one — the state lives in the part name because `::part(page)[aria-current='page']` is invalid CSS
and would silently never match. `first-button`/`first-icon` and `last-button`/`last-icon` exist only
while `with-edges` is set — and never in indeterminate mode, along with `pages`, `summary`, and
`page-count`, none of which have a total to render against (see indeterminate mode above).

`live-region` is a visually hidden, `aria-hidden` **mirror** of the applied-page announcement — a
styling and inspection surface, with no live-region role of its own. The announcement itself goes
to the library's shared **light-DOM** polite region, appended to the consumer's `<body>` and marked
`data-lr-live-region="polite"`, because a live region inside a shadow root is not reliably
announced (JAWS with Firefox ignores one outright). Assert against that document-level region
rather than `::part(live-region)`.

**CSS custom states:** `disabled` matches when the public `disabled` property is true. The state
does not match for the separate `loading` or empty-data conditions, even though those conditions
also make the rendered controls inert.

**Themeable custom properties:** `--lr-pagination-control-size` and
`--lr-pagination-font-size` (both default from `size`), `--lr-pagination-control-bg` (default
`var(--lr-color-surface)`) and `--lr-pagination-control-border-color` (default
`var(--lr-color-border)`) — the resting fill and border shared by the first/previous/next/last
buttons, interactive ellipses, and numbered pages, which is what `appearance` re-points: `filled` → `var(--lr-color-surface-raised)` + `transparent`,
`filled-outlined` → `var(--lr-color-surface-raised)` + `var(--lr-color-border)`, `plain` →
`transparent` + `transparent`, `accent` → `var(--lr-color-brand-quiet)` +
`var(--lr-color-brand)`; set either property yourself to theme past the five presets —
`--lr-pagination-control-radius` (default `var(--lr-radius)`) — border radius of the navigation
buttons, the numbered pages and the page input — `--lr-pagination-control-padding` (default
`var(--lr-space-xs)`) — inner padding of those same controls, deliberately uniform across every
`size` tier because the control's outer footprint is already fixed by
`--lr-pagination-control-size`, so this only adjusts the icon/digit inset. Layout spacing is split
across `--lr-pagination-base-gap` (default `var(--lr-space-m)`) between summary and controls,
`--lr-pagination-controls-gap` (default `var(--lr-space-xs)`) inside the navigation group, and
`--lr-pagination-pages-gap` (default `var(--lr-space-xs)`) between numbered pages; each remains
active in standard, compact, and 320px container layouts —
`--lr-pagination-invalid-border` (default `var(--lr-color-danger)`) — border color of
`[part="page-input"]` while the typed page is out of range (`aria-invalid="true"`); a state hook
declared as an inline `var()` fallback, since `::part(page-input)[aria-invalid='true']` is invalid
CSS. `--lr-pagination-control-color` independently controls the resting foreground. The applied
page has `--lr-pagination-current-bg`, `--lr-pagination-current-border-color`, and
`--lr-pagination-current-color`. Ordinary controls use `--lr-pagination-hover-bg`,
`--lr-pagination-hover-border-color`, `--lr-pagination-active-bg`, and
`--lr-pagination-active-border-color`; the applied page has independent
`--lr-pagination-current-hover-bg`, `--lr-pagination-current-hover-border-color`,
`--lr-pagination-current-active-bg`, and `--lr-pagination-current-active-border-color` hooks. Each
defaults to the exact shared brand/quiet-brand/active-mix treatment used previously. These state
hooks and the resting background/border hooks are consumed through inline fallbacks, so they work
when inherited from an ancestor as well as when set directly on one pager. Shared spacing,
disabled-opacity, and focus-ring tokens remain available as usual.

**Optional peer deps:** none.

```html
<lr-pagination
  total="237"
  page-size="20"
  with-summary
  with-edges
></lr-pagination>
<script>
  const pagination = document.querySelector("lr-pagination");
  pagination.addEventListener("lr-page-change", async (event) => {
    await loadPage(event.detail.page, event.detail.pageSize);
    pagination.page = event.detail.page;
  });
</script>

<!-- Link mode: real anchors, so the pager is crawlable and works before hydration. -->
<lr-pagination
  total="237"
  page-size="20"
  page="3"
  with-edges
  href-template="/products?page={page}"
></lr-pagination>
```

A function template builds the URL itself, which is where any dynamic segment gets encoded:

```js
const pagination = document.querySelector("lr-pagination");
const query = new URLSearchParams(location.search).get("q") ?? "";
pagination.hrefTemplate = (page) =>
  `/products?${new URLSearchParams({ q: query, page: String(page) })}`;
```

**Known gotchas:**

- user activation only emits an intent. Until the host applies a new `page`, the numeric input
  returns to the currently controlled value; assigning the page triggers the localized
  announcement in the shared light-DOM polite sink
- cancel `lr-before-page-change` for a policy veto; preventing `lr-page-change` has no effect because
  that second event is the accepted, non-cancelable controlled intent
- the jump input accepts only whole pages in `1..totalPages`; empty, fractional, and out-of-range
  drafts expose `aria-invalid="true"` and emit nothing
- zero items, zero page size, `disabled`, and `loading` all disable the navigation controls. The
  empty summary is still rendered via the localized `paginationEmptySummary` message
  (`'{total} {itemLabel}'`, producing `0 items` in the default locale) only when `with-summary` is set
- below a 20rem container allocation the summary and controls stack; the breakpoint responds to the
  component's own inline size, not the viewport. The control group and the page list wrap onto
  further rows at any width rather than overflowing. Previous/next and first/last icons all mirror
  under RTL
- link mode moves navigation outside the compact page field out of your handler. Numbered pages and
  ellipses exist only in standard layout; previous/next and first/last exist in both layouts. Each
  is a plain anchor with no click handler: activating it navigates and emits neither pagination
  event. The compact field continues to emit the controlled request events
- `href-template` is interpolated, not encoded. `{page}` is replaced with `String(page)` — always a
  plain integer, never the localized digits shown in the label — and the rest of the template is
  placed in the `href` verbatim: it is neither URL-encoded nor otherwise escaped. The only check
  applied is a scheme allowlist on the resolved URL (`http:`, `https:`, `blob:`, `mailto:`, and
  relative URLs); a `javascript:`, `data:` or unparseable result fails closed to a `<button>` for
  that page instead of being rendered. Treat the template as trusted application input and encode
  any dynamic segment yourself before it reaches the template — a function template is the
  straightforward place to do that
- the current page never gets an `href`, but while enabled it carries `tabindex="0"` so the applied
  location remains a keyboard stop. While `disabled` or `loading`, every anchor loses both `href`
  and the current page's explicit tabindex and carries `aria-disabled="true"`, leaving the inert
  pager with no tab stops
- an `hrefTemplate` function runs only for active targets inside `1..totalPages`. Current, spent,
  disabled, loading, and empty-state controls stay rendered as configured anchors without `href`,
  but do not call the function with a destination the user cannot activate
- `format="compact"` removes only the numbered list. `href-template` still applies to active
  previous/next and optional first/last targets around the page-jump input; spent boundary controls
  remain anchors without `href` and expose `aria-disabled="true"`
- the numbered list keeps a constant slot count as the reader pages through, so the control does not
  jitter: `siblingCount`/`boundaryCount` fix the budget, every page renders when the page count fits
  inside it, and otherwise a side that turns out to need no gap hands its slot back as one more page
  number. A gap is a named jump control, not decorative text; repeated activation advances through
  a large skipped run. Both counts are clamped to `25` and the render-every-page budget is capped at 101 slots, so
  however large you set them the list never renders more than 103 slots
- `appearance` does not reach the compact page-jump input — `[part="page-input"]` always draws with
  the shared surface and border tokens. Style `::part(page-input)` directly when a non-default
  appearance needs it to match

---
