Paginated Wave Grid
A paginated grid layout — on a page change the outgoing items exit in distance-based waves toward the turn direction while the new page's items flood in from the opposite side, with the page number and total rolling and morphing in sync.
npx shadcn@latest add https://webberui.com/r/paginated-wave-grid.jsonPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<PaginatedWaveGrid />
Installation
npx shadcn@latest add https://webberui.com/r/paginated-wave-grid.jsonOr, once registries are configured in components.json, install it as @webberui/paginated-wave-grid.
Usage
import { PaginatedWaveGrid } from "@/components/ui/paginated-wave-grid";
const items = Array.from({ length: 18 }, (_, i) => ({ id: i, label: `#${i}` }));
<PaginatedWaveGrid
items={items}
pageSize={6}
columns={3}
getItemKey={(item) => item.id}
renderItem={(item) => (
<div className="flex h-24 items-center justify-center rounded-xl border">
{item.label}
</div>
)}
/>;The turn direction is derived automatically from the previous and next page numbers: paging forward, the old page's items exit leftward in waves ordered by "distance from the left edge" while the new page's items flood in from the right; paging backward reverses the whole set. At the bottom, the page number and total roll digit by digit to their new values like an odometer.
Controlled mode
Passing page and onPageChange switches to controlled mode, letting it stay in sync with external state or other controls:
const [page, setPage] = React.useState(1);
<PaginatedWaveGrid page={page} onPageChange={setPage} items={items} renderItem={/* … */} />;Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | T[] | — | Data array, sliced into pages by pageSize |
renderItem | (item: T, index: number) => ReactNode | — | Render function for a single item |
pageSize | number | 6 | Items per page |
columns | number | 3 | Number of grid columns |
gap | number | 12 | Grid gap (px) |
travel | number | 40 | Horizontal offset of the wave as items enter and exit (px) |
waveStagger | number | 0.05 | Delay between each distance step (seconds), setting the speed of the ripple |
rowWeight | number | 0.5 | Weight of the row direction in the wave step count; >0 produces a diagonal ripple |
duration | number | 0.5 | Duration of a single item entering or exiting (seconds) |
blur | boolean | true | Whether entering and exiting are accompanied by a blur |
loop | boolean | false | Wrap around from the last page to the first and back |
page | number | — | Controlled current page number (1-based) |
defaultPage | number | 1 | Initial page number in uncontrolled mode |
onPageChange | (page: number) => void | — | Callback fired when the page number changes |
showControls | boolean | true | Whether to show the built-in pagination control bar |
getItemKey | (item: T, index: number) => Key | — | Produces a stable key for each item |
aria-label | string | "分頁波次網格" | Accessible label (the built-in default is Traditional Chinese — pass this prop to name it in your own language) |
className | string | — | Applied to the outermost container |
gridClassName | string | — | Applied to the grid container |
itemClassName | string | — | Applied to the wrapper of each item |
How it works
- The exit layer is absolutely positioned over the new page (
pointer-events-none), so the new page can refill the slots immediately while the old items fly away and fade out above it. - Each slot's wave step = leading-edge column distance + row distance ×
rowWeight, and exit and flood-in share the same step count, producing one continuous sweep. - The page number uses an odometer-style vertical film strip where each digit rolls independently to its target; when the digit count changes, keys are taken from the right, keeping the columns aligned.
- The transition timing is finished off with a
setTimeoutindependent of rAF, cleared on unmount and on the next page change.
Accessibility
- The built-in
previous page/next pagebuttons carryaria-labeland keyboard focus styles, and are disabled on the first and last page (not disabled whenloopis on). - When the container is focused, the
←/→arrow keys turn pages. - A hidden region with
aria-live="polite"announces "page X of Y" on every page change. - The odometer digits are hidden from assistive technology (
aria-hidden) so that digit-by-digit reading does not become noise. - When the user has "reduce motion" enabled at the system level, the wave offset, blur, and rolling are disabled and a page change switches the layout directly.