Pagination with page navigation, next and previous links. The current page's outline slides from link to link as the page changes.
Installation
Install the packages
Add the shadcn-vue component
Animate it
The CLI finds your shadcn-vue components through components.json, swaps their primitive imports for Animate UI's and removes the CSS animation classes Motion replaces.
Usage
Use shadcn-vue's pagination as usual.
<script setup lang="ts">
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination';
</script>
<template>
<Pagination v-slot="{ page }" :items-per-page="10" :total="50" :default-page="2">
<PaginationContent v-slot="{ items }">
<PaginationPrevious />
<template v-for="(item, index) in items" :key="index">
<PaginationItem v-if="item.type === 'page'" :value="item.value" :is-active="item.value === page">
{{ item.value }}
</PaginationItem>
</template>
<PaginationEllipsis :index="4" />
<PaginationNext />
</PaginationContent>
</Pagination>
</template>Animation
- One surface slides from the current link to the next current one, on a spring (stiffness 300, damping 30, the tabs' and the toggle group's selection spring): the outline moves along the row instead of jumping from link to link. It moves on the compositor: a Web Animation of its
translatethat replaces it (rather than adding to it), which Chromium runs off the main thread, so a heavy render after the click doesn't hold it up. Its size (widthandheight) is animated too only when the two links' sizes differ: a scale would distort the copied border, corners and shadow. Clicked again on the way (Next pressed twice quickly), it goes on from where it is with the speed it had; sent back the way it came, it turns back from where it is, without carrying on first. - It wears the current link's look: it copies the link's classes and data attributes (shadcn-vue's outline variant, reka-ui's
data-selected), so its border, background, corners, shadow and dark mode are the link's, in every style and theme. It doesn't copyaria-current: style the current page through the variant ordata-selected, shadcn-vue's way, not with anaria-[current=page]:class, which the surface wouldn't match while the link's own look is hidden. It's drawn over the backgrounds of the list and its items and under the links, while a small stylesheet hides the current link's own border, background and shadow as long as the surface stands in for them. - The link keeps its states: hovered (on devices that hover), the current link paints its own hover background (
hover:bg-accent), and the surface's own background fades away under it (200ms,ease), so the two don't add up: dark mode'sbg-input/30under the link'shover:bg-input/50would look lighter than the link hovered on its own. It only fades once the surface has arrived: while it slides, it keeps its background. Its border and shadow stay. A pagination nested in this one has its own surface: hovering that one's current link leaves this surface as it is. Focused from the keyboard, the link keeps its own focus ring (focus-visible:border-ring,ring-3). The surface itself is never hovered, pressed or focused. - It slides from where it was drawn, not from where the previous link is after the render: page numbers shifting in the same render as the current page (a window of pages around it) don't make it jump. A current page that stays in place (the numbers changing around it) doesn't move it.
- It lands where the link rests: the links are measured without their own transforms, those that move or scale them (a press nudge,
active:translate-y-px; a press scale, like Motion'swhilePress; a percentage translate,-translate-y-1/2), so a link clicked and still easing back doesn't leave it off by a pixel. At rest it follows the link at once when thePaginationRootor a link resizes (the Previous and Next labels showing fromsm, the page's layout changing), when text beside the links changes (a "Page 2 of 10" label), and when a list inside theRootscrolls. While an ancestor transforms the pagination (a dialog scaling or tilting in), the links are read again on each frame until it's drawn at its own size, so the surface doesn't keep a box read on the way. - Keyboard changes animate like clicks: Tab to a link, Enter.
- Nothing moves on mount, nor before the page has been painted: a page restored as it loads, or a pagination remounted by a navigation, shows its current page at once. In the frame the surface shows in, the current link's own look goes without its transition: shadcn-vue's
transition-allwould fade its border out over the surface, doubling it for a moment. A link that becomes current drops its border and shadow at once too (they don't transition), since shadcn-vue's ghost links have no transparent border to start from. - Under reduced motion it doesn't slide: it crossfades over 200ms on an ease-out (
cubic-bezier(0.23, 1, 0.32, 1)), a copy of it fading out on the link it leaves while it fades in on the new one. Changed again while it crossfades, the copy leaves from the opacity the surface had (it doesn't pop back to full), and the copies already leaving go on. <AnimateUIProvider :tempo>paces the slide, the crossfade and the hover fade.- The demo is shadcn-vue's
PaginationDemowith five pages (like the React demo): reka-ui keeps the page, the Previous and Next buttons stopping at the ends.
How it works
The PaginationRoot finds its current page: the first [data-type="page"][aria-current="page"] (reka-ui's PaginationListItem) inside it that's its own (not one of a pagination nested in it) and drawn (one hidden at this breakpoint, display: none, is skipped). It renders the surface as its first child: a <span data-slot="pagination-indicator" aria-hidden> positioned absolutely over the link's border box (box-sizing: border-box), at z-index: 1. The PaginationRoot gets position: relative (unless it's positioned already) and isolation: isolate, inline, so the surface is drawn inside it: over its background and the backgrounds of the list and its items (positioned or not), under the links and the ellipsis, which get position: relative and z-index: 2 once the surface is on screen (in @layer base, so a link's own absolute or fixed class wins; a static one would draw the link under the surface).
Once the surface is on screen, the PaginationRoot gets data-animate-ui-pagination-ready, and an unlayered stylesheet (it wins over Tailwind's layers) clears the current link's border-color and box-shadow (unless it's :focus-visible) and its background (unless it's hovered, on devices that hover). Under a hovered current link it clears the surface's background too, unless the surface is sliding (data-animate-ui-sliding): the surface's inline transition: background-color (transition.color) fades it. In the frame the surface shows in, the PaginationRoot also has data-animate-ui-pagination-settling, which turns the current link's transition off.
The slide is a Web Animation replacing the surface's translate, from its own translate plus the distance to its own (added to it only when its own isn't in pixels), so Chromium runs it on the compositor; a second one animates its width and height when the links' sizes differ. A MutationObserver (elements added or removed, text changing, and the aria-current, class, data-selected and data-disabled attributes), a ResizeObserver (the PaginationRoot and the links) and a capturing scroll listener (a list scrolling inside the PaginationRoot) keep the surface on the current link, measured in the PaginationRoot's own pixels whatever an ancestor's scale. While the PaginationRoot is drawn at another size than its own (an ancestor's transform), the links are read again on each frame, 90 frames at most. Under reduced motion, the copy fading out is another span with data-animate-ui-leaving, removed once it has faded.
API Reference
Every part takes exactly the props of reka-ui's Pagination. Animate UI animates PaginationRoot; the other parts are reka-ui's.
PaginationRoot
reka-ui's PaginationRoot, with the same props and events. Its pages are the [data-type="page"] elements inside it (reka-ui's PaginationListItem, shadcn-vue's PaginationItem), the current one the page with aria-current="page" (its own, and drawn). On itself, it sets:
data-animate-ui-pagination, always: eachPaginationRootonly animates its own pages.data-animate-ui-pagination-readyonce the surface is on screen: the current page's own border, shadow and background are hidden then.data-animate-ui-pagination-settlingin the frame the surface shows in: the current page'stransitionis off.position: relative(unless it's positioned already) andisolation: isolate, inline.
It adds no attribute to the pages: once the surface is on screen, its stylesheet gives them and the ellipsis ([data-type="ellipsis"]) position: relative, in @layer base, so a class of yours wins. Its first child is the surface, a <span data-slot="pagination-indicator" aria-hidden> with data-animate-ui-sliding while it slides; under reduced motion, the copies fading out carry data-animate-ui-leaving.
| Prop | Type | Default |
|---|---|---|
transition? | Transition | { type: 'spring', stiffness: 300, damping: 30 } |
AnimateUIProvider's tempo scales it with everything else.
Accessibility
The pages stay reka-ui's: the primitive reads their aria-current but never sets it, and adds no role, tabindex or handler to them. Focus, Tab and Enter work as before. The surface (and its fading copy) is aria-hidden and never takes pointer events: screen readers and clicks reach the pages as before. It sits in the <nav>, outside the list, so the list's items are the same.
Credits
- shadcn-vue's Pagination.
- The motion is inspired by EasyUI's Slide Pagination.