# PageControl

**Maturity:** stable

is the bare dot indicator for a paged collection — the row of neutral dots that shows how many pages exist, which one is current, and lets the user jump between them.

## Import

```tsx
import { PageControl } from "@liquidify/react/page-control"
```

## Props

- `allowsContinuousInteraction`: `boolean | undefined`; default ``true``. Enables press-drag scrubbing across the dot row (`UIPageControl`'s continuous interaction): the pressed pointer commits the dot it settles over, dot-to-dot, until release. When `false`, only a discrete dot tap (and the keyboard) can change the page, and `backgroundDisplay="interacting"` never reveals its plate — there is no scrub to reveal it for.
- `aria-label`: `string | undefined`. Required accessible name (the tablist renders no visible label).
- `aria-labelledby`: `string | undefined`. Required accessible name via reference (alternative to `aria-label`).
- `backgroundDisplay`: `"automatic" | "always" | "never" | "interacting" | undefined`; default ``"automatic"``. Whether/when the frosted backing plate renders. `"always"` keeps it mounted; `"interacting"` mounts it only while the user is scrubbing (a held pointer that has travelled — a plain dot tap never reveals it); `"automatic"` / `"never"` never mount it. `"interacting"` therefore has no effect under `allowsContinuousInteraction={false}`, where no scrub exists. Maps SwiftUI `PageIndexViewStyle.BackgroundDisplayMode.interactive` (the plate reveals only during a live dot interaction) to the React value `"interacting"` — a deliberately different spelling from the removed `GlassMaterial` tier of the same era (D6, ADR-0010), which `material_scale.py` bans from `packages/*` source on an unrelated axis (the glass-surface material, not this presentational display enum). See spec.md `## Variants` for the full SwiftUI → React naming rationale.
- `className`: `string | undefined`. Optional additional CSS class merged into the glass surface class. The supported per-instance customisation seam: override any `--lq-*` custom property from this class's CSS — the token cascade the engine reads (ADR-0004). No raw inline style is involved, so this stays ADR-0003 clean.
- `count`: `number` (required). Total number of pages (`UIPageControl.numberOfPages`). Required.
- `defaultValue`: `number | undefined`; default ``0``. Uncontrolled seed for the current page index.
- `disabled`: `boolean | undefined`; default ``false``. Disables the whole control.
- `hidesForSinglePage`: `boolean | undefined`; default ``true``. Hide the row when `count === 1` under `indexDisplay="automatic"`.
- `id`: `string | undefined`. Base id for the deterministic per-dot ids (`${id}-tab-${i}`).
- `indexDisplay`: `"automatic" | "always" | "never" | undefined`; default ``"automatic"``. Whether the dot row renders.
- `onChange`: `((index: number) => void) | undefined`. Fires with the next 0-based index whenever the page changes.
- `ref`: `Ref<HTMLDivElement> | undefined`. Forwarded ref to the rendered element — the component's native root (or the merged child element on a component that opts into {@link SlottableProps}). Generic over `E` (charter `docs/api-conventions.md` §4 rule 1, default `HTMLElement`) so a component with a polymorphic root (Link's navigation / action mode union, Text's `as`) can instantiate the concrete element per variant instead of leaving every consumer with a widened `Ref<HTMLElement>`. A component that does not narrow simply inherits the `HTMLElement` default — source-compatible with every pre-existing non-generic `extends ControlProps`.
- `size`: `PageControlSize | undefined`; default ``"regular"``. Size of the dot row — the `regular · large` subset of the canonical {@link SizedControlProps.sizesize scale} (ADR-0018). Resolved to geometry via `data-size` attribute selectors in `styles.css`; the root stamps `data-size` **unconditionally** (the Button/Toggle pattern), so both values are always addressable in CSS and tests.
- `value`: `number | undefined`. Controlled current page index (0-based).

## SwiftUI mapping

- ``UIPageControl.numberOfPages`` → ``count: number` (required)` (direct): Total dots; drives the row length.
- ``UIPageControl.currentPage`` → ``value` / `defaultValue` (0-based)` (direct): Controlled triad (ADR-0015/ADR-0017 — the library-wide value-bearing naming convention, chosen over a domain-specific `page`/`defaultPage` pair so `controlled_props` recognises the triad); `value` controlled, `defaultValue` uncontrolled seed (default `0`).
- `page change (tap / scrub / keyboard)` → ``onChange?: (index: number) => void`` (direct): Fires the new 0-based index on activation.
- ``PageTabViewStyle.IndexDisplayMode` `.always`/`.automatic`/`.never`` → ``indexDisplay?: "always" | "automatic" | "never"`` (direct): Whether the dot row renders; default `"automatic"`.
- ``PageIndexViewStyle.BackgroundDisplayMode` `.always`/`.interactive`/`.automatic`/`.never` (≈ `backgroundStyle` `.prominent`/`.automatic`/`.minimal`)` → ``backgroundDisplay?: "always" | "interacting" | "automatic" | "never"`` (direct): Frosted plate visibility; default `"automatic"` (= no plate here). React spells the SwiftUI `.interactive` case `"interacting"` — `material_scale.py` bans the former as the removed `GlassMaterial` tier.
- ``UIPageControl.hidesForSinglePage`` → ``hidesForSinglePage?: boolean` (default `true`)` (direct): Hide the row when `count === 1`. **Deliberate divergence:** UIKit applies it unconditionally, React folds it into `indexDisplay="automatic"` only — `"always"` shows a lone dot, `"never"` hides regardless (resolves open question 7).
- ``UIPageControl.allowsContinuousInteraction`` → ``allowsContinuousInteraction?: boolean` (default `true`)` (direct): Press-drag scrubs the row dot-to-dot, committing each dot the pointer settles over (resolves open question 1). `false` leaves the discrete dot tap + keyboard, and makes `backgroundDisplay="interacting"` inert (no scrub to reveal the plate for).
- ``UIPageControl.direction` `.natural`/`.leftToRight`/`.rightToLeft`` → `(follow CSS `dir`; no prop)` (direct): LTR/RTL from the effective document direction; the pointer→index mapping measures the painted dot rects, so it mirrors for free.
- ``UIPageControl.direction` `.topToBottom`/`.bottomToTop`` → `(none)` (direct): Vertical page controls are out of scope for v1; the root is `aria-orientation="horizontal"` (see Out of scope).
- ``UIPageControl.preferredIndicatorImage` / `setIndicatorImage(_:forPage:)`` → `(none)` (direct): Per-page symbol indicators are out of scope; every dot is the same neutral circle (see Out of scope).
- `(no Apple equivalent)` → ``size?: PageControlSize` (`"regular" | "large"`, default `"regular"`)` (web-only): A web-only geometry axis (ADR-0018): `UIPageControl` ships one dot metric, but a full-bleed web pager needs a legible step up. Not a `.controlSize(_:)` mapping — see Sizes.
- ``UIPageControl.progress` (`UIPageControlProgress` / `UIPageControlTimerProgress`)` → `(none)` (direct): The progress/timer page-control styles are out of scope (see Out of scope).
- ``label: () -> Label` (accessible name)` → ``aria-label` / `aria-labelledby` (required)` (direct): The tablist renders no visible label of its own.
- ``isEnabled` (env)` → ``disabled?: boolean`` (direct): Standard control convention; dims + removes from tab order.
- ``currentPageIndicatorTintColor` / `pageIndicatorTintColor`` → `(no prop — fixed neutral tokens)` (direct): Apple keeps the dots neutral glass; no tint axis (ADR-0019).
