Pagination
A row of links, and not one line of JavaScript in the component.
src/components/rahti_ui/pagination.rs
Basic
Previous, a run of numbers, an ellipsis and Next.
shadcn's own demo, part for part. The current page is `active`, which switches the link from the `ghost` variant to `outline` and writes `aria-current="page"` — the border you can see and the announcement you cannot are the same prop.
Every control is inside a `<PaginationItem>`, because the row is a real `<ul>` and a screen reader counts it: "list, 6 items". The `<nav>` around it is a landmark, which is how someone skips straight to it.
Simple
Only page numbers.
Numbers alone. Nothing is required in the row — the component is seven parts and no rules about which of them appear.
Icons only
The two controls without the numbers.
Previous and Next on their own, which is what a data table usually wants — there the row count is in a footer and the numbers would be noise. Narrow the window and the labels disappear: they are `hidden sm:block`, so below `sm` the chevrons stand alone.
Without JavaScript
The case the component is built for: links that already work.
Real `href`s, so this row is finished the moment the HTML arrives: it navigates, it opens in a new tab on a middle click, it is crawlable, and it does all of that with the runtime switched off. shadcn's demos write `href="#"`, which is a placeholder rather than a recommendation.
The `aria_label` is changed here too. shadcn hard-codes `aria-label="pagination"`, which is right until a page has two of them — a landmark list with two identical names is no more use than one with none.
Labels
The chevrons' words, for a page that is not in English.
`text` is the one thing taken from shadcn's newer file, which added the prop. A hard-coded English word is not something a caller should have to re-implement the component to change, and it costs nothing to accept.
The `aria-label` on each control stays English here on purpose — it is `Go to previous page` either way, and translating it is a separate decision the component does not make for you.
The port
Where the class strings came from, and the one place this library follows the registry instead of the page.
This is the one component in this library where the **registry wins over the docs page**, which reverses the rule every other file follows. The newer pagination shadcn publishes differs throughout: `gap-0.5` on the row rather than `gap-1`, `pl-1.5!` on the controls rather than `gap-1 px-2.5 sm:pl-2.5`, and a `size-8` ellipsis rather than `size-9`.
Those are not drift, they are tuned against a different Button. The page's own rendered markup shows its links carrying `group/button`, `rounded-lg`, `focus-visible:ring-3` and `size-8`; this library's Button is the new-york-v4 generation — `rounded-md`, `focus-visible:ring-[3px]`, `size-9`. Take the newer strings onto the older Button and a `size-8` ellipsis sits beside `size-9` links, and a gap chosen for 32px targets is applied to 36px ones.
`data-icon="inline-start"` goes the same way. The newer Button reads it with `has-data-[icon=inline-start]:pl-2`; this one selects on it nowhere, so writing it would be decoration. `cn-rtl-flip` is dropped for the reason `Empty` dropped `cn-font-heading` — a style hook whose definition lives in a stylesheet this library does not ship.
When `button.rs` moves to the newer generation, this component moves with it, and those four strings are the whole of the change. A test names each of them so that half-migrating fails loudly.
The boundary
The one place a join is not a merge, and what it cost.
shadcn's `cn()` is tailwind-merge; this library's `cn` is a join. That is usually invisible, and here it was not: the controls pass `gap-1 px-2.5` precisely to beat the Button's `gap-2 px-4`, and a join would have written the loser beside the winner and left Tailwind's stylesheet order to pick — which picks the larger value, so the controls would have rendered with the Button's padding.
So the control composes `BASE` + variant + a size layer that never had the two conflicting tokens in it. Same result, nothing to resolve — the argument `ButtonSize` already makes for spelling every size out rather than overriding a shared one. `audit::no_utility_contradicts_another` checks the composed string rather than the fragments, so it would have caught this either way.
Nothing in this component is reactive, and nothing in it needs a scope. Which page is current is a prop; the links are links. A page that wants browser state instead builds the row from `pagination_link_variants` in its own block — the section below — for the usual reason: a component is a PulsePoint scope of its own and its children cannot see the page's state.
A window around the current page
shadcn's component does not compute a range and neither does this one — which numbers to show is the page's decision. This is the usual rule: first, last, and a window either side of the current page, with an ellipsis wherever the run is broken.
Page {current} of {TOTAL}
Written as elements rather than tags, and that is the scope rule rather than a preference: a `<PaginationLink>` is a component, so children handed to it compile against *its* scope, which has no `current`. The `pagination_link_variants` helper exists for exactly this — it is the class list without the component.
Every anchor here carries `pp-spa="false"` *and* calls `preventDefault`, and it needs both. These are real `<a href>`s, so the SPA router intercepts the click and re-renders the route — which rebuilds this block and puts `current` back to 6, so the row looked frozen. The router decides in the capture phase, before a handler on the element has run, so cancelling the default alone was not enough; `pp-spa="false"` is the documented opt-out and `preventDefault` then stops the browser navigating for real. `href="#"` is exactly the shape that trips this.
The ellipsis and the number share one `<li>`, with `hidden` deciding which is drawn. One `pp-for` cannot emit a different element per row, and two loops would need two namespaced keys — this is the smaller arrangement.