Saved views
v2.6.0 · September 22, 2026
- NewSave any filter and sort combination as a named view.
- NewShare views with your workspace from the view menu.
- FixedColumn widths no longer reset after a refresh.
Release 1 of 5
Free, copy-and-go Pagination components built on the SevenUI Pagination primitive.Read the primitive docs.
"use client";
import * as React from "react";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
const TOTAL_PAGES = 24;
type PageToken = number | "start-ellipsis" | "end-ellipsis";
// Always show the first and last page, the current page, and one sibling on
// each side; collapse everything else into an ellipsis.
function getPageTokens(current: number, total: number): PageToken[] {
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1);
}
const start = Math.max(2, Math.min(current - 1, total - 4));
const end = Math.min(total - 1, Math.max(current + 1, 5));
const tokens: PageToken[] = [1];
if (start > 2) tokens.push("start-ellipsis");
for (let page = start; page <= end; page++) tokens.push(page);
if (end < total - 1) tokens.push("end-ellipsis");
tokens.push(total);
return tokens;
}
// Phone-width variant: first, current, and last page only, with ellipses
// marking every gap so the collapsed range stays legible.
function getCompactTokens(current: number, total: number): PageToken[] {
const pages = Array.from(new Set([1, current, total])).sort((a, b) => a - b);
const tokens: PageToken[] = [];
pages.forEach((value, index) => {
const previous = pages[index - 1];
if (previous !== undefined && value - previous === 2) {
tokens.push(previous + 1);
} else if (previous !== undefined && value - previous > 2) {
tokens.push(index === 1 ? "start-ellipsis" : "end-ellipsis");
}
tokens.push(value);
});
return tokens;
}
function renderToken(
token: PageToken,
className: string,
keyPrefix: string,
page: number,
go: (event: React.MouseEvent<HTMLAnchorElement>, next: number) => void,
) {
if (typeof token !== "number") {
return (
<PaginationItem key={`${keyPrefix}-${token}`} className={className}>
<PaginationEllipsis />
</PaginationItem>
);
}
return (
<PaginationItem key={`${keyPrefix}-${token}`} className={className}>
<PaginationLink
href={`#page-${token}`}
aria-label={`Page ${token}`}
isActive={token === page}
className="tabular-nums"
onClick={(event) => go(event, token)}
>
{token}
</PaginationLink>
</PaginationItem>
);
}
const disabledClass = "pointer-events-none opacity-50";
export default function Pagination01() {
const [page, setPage] = React.useState(6);
const tokens = getPageTokens(page, TOTAL_PAGES);
const compactTokens = getCompactTokens(page, TOTAL_PAGES);
const isFirst = page === 1;
const isLast = page === TOTAL_PAGES;
function go(event: React.MouseEvent<HTMLAnchorElement>, next: number) {
event.preventDefault();
setPage(Math.min(TOTAL_PAGES, Math.max(1, next)));
}
return (
<Pagination className="w-full max-w-md">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={`#page-${page - 1}`}
aria-disabled={isFirst || undefined}
tabIndex={isFirst ? -1 : undefined}
className={isFirst ? disabledClass : undefined}
onClick={(event) => go(event, page - 1)}
/>
</PaginationItem>
{tokens.map((token) =>
renderToken(token, "hidden sm:block", "wide", page, go),
)}
{compactTokens.map((token) =>
renderToken(token, "sm:hidden", "compact", page, go),
)}
<PaginationItem>
<PaginationNext
href={`#page-${page + 1}`}
aria-disabled={isLast || undefined}
tabIndex={isLast ? -1 : undefined}
className={isLast ? disabledClass : undefined}
onClick={(event) => go(event, page + 1)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
}
npx shadcn@latest add @sevenui/component/pagination-01pnpm dlx shadcn@latest add @sevenui/component/pagination-01yarn dlx shadcn@latest add @sevenui/component/pagination-01bunx --bun shadcn@latest add @sevenui/component/pagination-01Compact
Dense tables and toolbars
Default
Lists and search results
Comfortable
Touch-first and marketing pages
"use client";
import * as React from "react";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
const densities = [
{
id: "compact",
label: "Compact",
hint: "Dense tables and toolbars",
pageSize: "icon-sm",
stepSize: "sm",
text: "text-xs",
},
{
id: "default",
label: "Default",
hint: "Lists and search results",
pageSize: "icon",
stepSize: "default",
text: "text-sm",
},
{
id: "comfortable",
label: "Comfortable",
hint: "Touch-first and marketing pages",
pageSize: "icon-lg",
stepSize: "lg",
text: "text-sm",
},
] as const;
const pages = [1, 2, 3, 4, 5];
function SizedPagination({
density,
}: {
density: (typeof densities)[number];
}) {
const [page, setPage] = React.useState(2);
function go(event: React.MouseEvent<HTMLAnchorElement>, next: number) {
event.preventDefault();
setPage(Math.min(pages.length, Math.max(1, next)));
}
// Below sm, show a three-page window around the current page so the
// larger densities still fit a phone-width row.
const windowStart = Math.min(Math.max(page - 1, 1), pages.length - 2);
return (
<Pagination
aria-label={`${density.label} pagination`}
className="w-auto justify-start"
>
<PaginationContent className={density.stepSize === "lg" ? "gap-1" : ""}>
<PaginationItem>
<PaginationPrevious
href={`#${density.id}-page-${page - 1}`}
size={density.stepSize}
aria-disabled={page === 1 || undefined}
tabIndex={page === 1 ? -1 : undefined}
className={page === 1 ? "pointer-events-none opacity-50" : ""}
onClick={(event) => go(event, page - 1)}
/>
</PaginationItem>
{pages.map((item) => (
<PaginationItem
key={item}
className={
item < windowStart || item > windowStart + 2
? "hidden sm:block"
: undefined
}
>
<PaginationLink
href={`#${density.id}-page-${item}`}
size={density.pageSize}
isActive={item === page}
aria-label={`Page ${item}`}
className={`tabular-nums ${density.text}`}
onClick={(event) => go(event, item)}
>
{item}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href={`#${density.id}-page-${page + 1}`}
size={density.stepSize}
aria-disabled={page === pages.length || undefined}
tabIndex={page === pages.length ? -1 : undefined}
className={
page === pages.length ? "pointer-events-none opacity-50" : ""
}
onClick={(event) => go(event, page + 1)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
}
export default function Pagination02() {
return (
<div className="flex w-full max-w-md flex-col divide-y divide-border">
{densities.map((density) => (
<div
key={density.id}
className="flex flex-col gap-3 py-4 first:pt-0 last:pb-0"
>
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<p className="text-sm font-medium">{density.label}</p>
<p className="text-xs text-muted-foreground">{density.hint}</p>
</div>
<SizedPagination density={density} />
</div>
))}
</div>
);
}
npx shadcn@latest add @sevenui/component/pagination-02pnpm dlx shadcn@latest add @sevenui/component/pagination-02yarn dlx shadcn@latest add @sevenui/component/pagination-02bunx --bun shadcn@latest add @sevenui/component/pagination-02"use client";
import * as React from "react";
import { cn } from "cn";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
} from "@/components/ui/pagination";
const TOTAL_PAGES = 18;
type PageToken = number | "start-ellipsis" | "end-ellipsis";
function getPageTokens(current: number, total: number): PageToken[] {
if (current <= 3) return [1, 2, 3, 4, "end-ellipsis", total];
if (current >= total - 2) {
return [1, "start-ellipsis", total - 3, total - 2, total - 1, total];
}
return [1, "start-ellipsis", current, current + 1, "end-ellipsis", total];
}
// Joined cells: square inner corners, a hairline divider, and a filled
// primary cell for the current page instead of the default outline.
const cellClass =
"h-8 min-w-8 rounded-none border-0 px-2 tabular-nums focus-visible:z-10 focus-visible:ring-inset sm:h-9 sm:min-w-9 sm:px-3";
const activeClass =
"bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground dark:bg-primary dark:hover:bg-primary/90";
const disabledClass = "pointer-events-none text-muted-foreground/50";
export default function Pagination03() {
const [page, setPage] = React.useState(1);
const tokens = getPageTokens(page, TOTAL_PAGES);
function go(event: React.MouseEvent<HTMLAnchorElement>, next: number) {
event.preventDefault();
setPage(Math.min(TOTAL_PAGES, Math.max(1, next)));
}
return (
<Pagination className="w-full max-w-md">
<PaginationContent className="gap-0 divide-x divide-border overflow-hidden rounded-lg border border-border bg-card shadow-xs">
<PaginationItem>
<PaginationLink
href={`#page-${page - 1}`}
aria-label="Go to previous page"
aria-disabled={page === 1 || undefined}
tabIndex={page === 1 ? -1 : undefined}
className={cn(cellClass, page === 1 && disabledClass)}
onClick={(event) => go(event, page - 1)}
>
<ChevronLeftIcon aria-hidden="true" className="cn-rtl-flip" />
</PaginationLink>
</PaginationItem>
{tokens.map((token) =>
typeof token === "number" ? (
<PaginationItem key={token}>
<PaginationLink
href={`#page-${token}`}
aria-label={`Page ${token}`}
isActive={token === page}
className={cn(cellClass, token === page && activeClass)}
onClick={(event) => go(event, token)}
>
{token}
</PaginationLink>
</PaginationItem>
) : (
<PaginationItem key={token}>
<PaginationEllipsis className="text-muted-foreground sm:size-9" />
</PaginationItem>
),
)}
<PaginationItem>
<PaginationLink
href={`#page-${page + 1}`}
aria-label="Go to next page"
aria-disabled={page === TOTAL_PAGES || undefined}
tabIndex={page === TOTAL_PAGES ? -1 : undefined}
className={cn(cellClass, page === TOTAL_PAGES && disabledClass)}
onClick={(event) => go(event, page + 1)}
>
<ChevronRightIcon aria-hidden="true" className="cn-rtl-flip" />
</PaginationLink>
</PaginationItem>
</PaginationContent>
</Pagination>
);
}
npx shadcn@latest add @sevenui/component/pagination-03pnpm dlx shadcn@latest add @sevenui/component/pagination-03yarn dlx shadcn@latest add @sevenui/component/pagination-03bunx --bun shadcn@latest add @sevenui/component/pagination-03"use client";
import * as React from "react";
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
} from "lucide-react";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
} from "@/components/ui/pagination";
const TOTAL_PAGES = 36;
export default function Pagination04() {
const [page, setPage] = React.useState(4);
const isFirst = page === 1;
const isLast = page === TOTAL_PAGES;
const steps = [
{
label: "Go to first page",
target: 1,
disabled: isFirst,
icon: ChevronsLeftIcon,
},
{
label: "Go to previous page",
target: page - 1,
disabled: isFirst,
icon: ChevronLeftIcon,
},
];
const forwardSteps = [
{
label: "Go to next page",
target: page + 1,
disabled: isLast,
icon: ChevronRightIcon,
},
{
label: "Go to last page",
target: TOTAL_PAGES,
disabled: isLast,
icon: ChevronsRightIcon,
},
];
function renderStep(step: (typeof steps)[number]) {
const Icon = step.icon;
return (
<PaginationItem key={step.label}>
<PaginationLink
href={`#page-${step.target}`}
aria-label={step.label}
aria-disabled={step.disabled || undefined}
tabIndex={step.disabled ? -1 : undefined}
className={
step.disabled ? "pointer-events-none opacity-40" : undefined
}
onClick={(event) => {
event.preventDefault();
setPage(Math.min(TOTAL_PAGES, Math.max(1, step.target)));
}}
>
<Icon aria-hidden="true" className="cn-rtl-flip" />
</PaginationLink>
</PaginationItem>
);
}
return (
<Pagination className="w-full max-w-xs">
<PaginationContent className="w-full">
{steps.map(renderStep)}
<PaginationItem
aria-live="polite"
className="flex-1 px-2 text-center text-sm whitespace-nowrap text-muted-foreground"
>
Page{" "}
<span className="font-medium text-foreground tabular-nums">
{page}
</span>{" "}
of <span className="tabular-nums">{TOTAL_PAGES}</span>
</PaginationItem>
{forwardSteps.map(renderStep)}
</PaginationContent>
</Pagination>
);
}
npx shadcn@latest add @sevenui/component/pagination-04pnpm dlx shadcn@latest add @sevenui/component/pagination-04yarn dlx shadcn@latest add @sevenui/component/pagination-04bunx --bun shadcn@latest add @sevenui/component/pagination-04"use client";
import * as React from "react";
import { cn } from "cn";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
} from "@/components/ui/pagination";
const tips = [
{
title: "Invite your team",
body: "Add teammates from Settings → Members. Everyone you invite joins the workspace as an editor until you change their role.",
},
{
title: "Connect a data source",
body: "Link Postgres, BigQuery, or a CSV upload. Schemas sync every 15 minutes, and you can trigger a manual refresh any time.",
},
{
title: "Pin your first dashboard",
body: "Pinned dashboards open on launch for the whole team, so the numbers everyone checks each morning are one click away.",
},
{
title: "Set up alerts",
body: "Get a Slack or email message when a metric crosses a threshold. Alerts evaluate on every sync, not once a day.",
},
];
export default function Pagination05() {
const [index, setIndex] = React.useState(0);
const tip = tips[index];
const isFirst = index === 0;
const isLast = index === tips.length - 1;
function go(event: React.MouseEvent<HTMLAnchorElement>, next: number) {
event.preventDefault();
setIndex(Math.min(tips.length - 1, Math.max(0, next)));
}
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardDescription className="tabular-nums">
Tip {index + 1} of {tips.length}
</CardDescription>
<CardTitle aria-live="polite">{tip.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="min-h-15 text-sm text-pretty text-muted-foreground">
{tip.body}
</p>
</CardContent>
<CardFooter>
<Pagination aria-label="Getting started tips">
<PaginationContent className="w-full justify-between">
<PaginationItem>
<PaginationLink
href={`#tip-${index}`}
aria-label="Previous tip"
aria-disabled={isFirst || undefined}
tabIndex={isFirst ? -1 : undefined}
className={cn(isFirst && "pointer-events-none opacity-40")}
onClick={(event) => go(event, index - 1)}
>
<ArrowLeftIcon aria-hidden="true" className="cn-rtl-flip" />
</PaginationLink>
</PaginationItem>
<li className="flex items-center">
<ul className="flex items-center">
{tips.map((item, dot) => (
<PaginationItem key={item.title}>
{/* A 24px hit area wraps the small visual dot, which
stretches into a pill for the current tip. */}
<PaginationLink
href={`#tip-${dot + 1}`}
size="xs"
aria-label={`Tip ${dot + 1}: ${item.title}`}
isActive={dot === index}
className="group/dot min-w-6 border-transparent bg-transparent px-1 hover:bg-transparent dark:border-transparent dark:bg-transparent dark:hover:bg-transparent"
onClick={(event) => go(event, dot)}
>
<span
aria-hidden="true"
className={cn(
"block h-1.5 rounded-full transition-[width,background-color] duration-300 ease-out motion-reduce:transition-none",
dot === index
? "w-5 bg-primary"
: "w-1.5 bg-muted-foreground/30 group-hover/dot:bg-muted-foreground/60",
)}
/>
</PaginationLink>
</PaginationItem>
))}
</ul>
</li>
<PaginationItem>
<PaginationLink
href={`#tip-${index + 2}`}
aria-label="Next tip"
aria-disabled={isLast || undefined}
tabIndex={isLast ? -1 : undefined}
className={cn(isLast && "pointer-events-none opacity-40")}
onClick={(event) => go(event, index + 1)}
>
<ArrowRightIcon aria-hidden="true" className="cn-rtl-flip" />
</PaginationLink>
</PaginationItem>
</PaginationContent>
</Pagination>
</CardFooter>
</Card>
);
}
npx shadcn@latest add @sevenui/component/pagination-05pnpm dlx shadcn@latest add @sevenui/component/pagination-05yarn dlx shadcn@latest add @sevenui/component/pagination-05bunx --bun shadcn@latest add @sevenui/component/pagination-05Showing 1–25 of 118 invoices
"use client";
import * as React from "react";
import { cn } from "cn";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Spinner } from "@/components/ui/spinner";
const TOTAL_PAGES = 5;
const PAGE_SIZE = 25;
const TOTAL_ITEMS = 118;
const LATENCY_MS = 900;
export default function Pagination06() {
const [page, setPage] = React.useState(1);
const [pending, setPending] = React.useState<number | null>(null);
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Clear a request still in flight when the example unmounts.
React.useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
function request(event: React.MouseEvent<HTMLAnchorElement>, next: number) {
event.preventDefault();
if (pending !== null || next === page || next < 1 || next > TOTAL_PAGES) {
return;
}
setPending(next);
// Simulates the round trip to the server before the page commits.
timer.current = setTimeout(() => {
setPage(next);
setPending(null);
}, LATENCY_MS);
}
const busy = pending !== null;
const first = (page - 1) * PAGE_SIZE + 1;
const last = Math.min(page * PAGE_SIZE, TOTAL_ITEMS);
const lockClass = "pointer-events-none opacity-50";
return (
<div className="flex w-full max-w-md flex-col items-center gap-3">
<Pagination aria-busy={busy}>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={`#page-${page - 1}`}
aria-disabled={busy || page === 1 || undefined}
tabIndex={busy || page === 1 ? -1 : undefined}
className={cn((busy || page === 1) && lockClass)}
onClick={(event) => request(event, page - 1)}
/>
</PaginationItem>
{Array.from({ length: TOTAL_PAGES }, (_, index) => index + 1).map(
(item) => {
const isPending = item === pending;
return (
<PaginationItem key={item}>
<PaginationLink
href={`#page-${item}`}
aria-label={`Page ${item}`}
isActive={item === page}
aria-disabled={(busy && !isPending) || undefined}
className={cn(
"tabular-nums",
busy && "pointer-events-none",
busy && !isPending && item !== page && "opacity-50",
isPending && "bg-muted text-foreground",
)}
onClick={(event) => request(event, item)}
>
{isPending ? (
<Spinner aria-label={`Loading page ${item}`} />
) : (
item
)}
</PaginationLink>
</PaginationItem>
);
},
)}
<PaginationItem>
<PaginationNext
href={`#page-${page + 1}`}
aria-disabled={busy || page === TOTAL_PAGES || undefined}
tabIndex={busy || page === TOTAL_PAGES ? -1 : undefined}
className={cn((busy || page === TOTAL_PAGES) && lockClass)}
onClick={(event) => request(event, page + 1)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
<p
aria-live="polite"
className="text-sm text-muted-foreground tabular-nums"
>
{busy ? (
`Loading page ${pending}…`
) : (
<>
Showing{" "}
<span className="font-medium text-foreground">
{first}–{last}
</span>{" "}
of {TOTAL_ITEMS} invoices
</>
)}
</p>
</div>
);
}
npx shadcn@latest add @sevenui/component/pagination-06pnpm dlx shadcn@latest add @sevenui/component/pagination-06yarn dlx shadcn@latest add @sevenui/component/pagination-06bunx --bun shadcn@latest add @sevenui/component/pagination-06"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
const TOTAL_PAGES = 48;
type PageToken = number | "start-ellipsis" | "end-ellipsis";
function getPageTokens(current: number, total: number): PageToken[] {
if (current <= 3) return [1, 2, 3, "end-ellipsis", total];
if (current >= total - 2) {
return [1, "start-ellipsis", total - 2, total - 1, total];
}
return [1, "start-ellipsis", current, "end-ellipsis", total];
}
export default function Pagination07() {
const [page, setPage] = React.useState(12);
const [draft, setDraft] = React.useState("");
const [error, setError] = React.useState<string | null>(null);
const inputId = React.useId();
const errorId = React.useId();
const tokens = getPageTokens(page, TOTAL_PAGES);
function go(event: React.MouseEvent<HTMLAnchorElement>, next: number) {
event.preventDefault();
setPage(Math.min(TOTAL_PAGES, Math.max(1, next)));
setError(null);
}
function jump(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const value = Number(draft);
if (!draft.trim() || !Number.isInteger(value)) {
setError("Enter a whole page number.");
return;
}
if (value < 1 || value > TOTAL_PAGES) {
setError(`Choose a page between 1 and ${TOTAL_PAGES}.`);
return;
}
setPage(value);
setDraft("");
setError(null);
}
return (
<div className="flex w-full max-w-md flex-col gap-4">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={`#page-${page - 1}`}
aria-disabled={page === 1 || undefined}
tabIndex={page === 1 ? -1 : undefined}
className={page === 1 ? "pointer-events-none opacity-50" : ""}
onClick={(event) => go(event, page - 1)}
/>
</PaginationItem>
{tokens.map((token) =>
typeof token === "number" ? (
<PaginationItem key={token}>
<PaginationLink
href={`#page-${token}`}
aria-label={`Page ${token}`}
isActive={token === page}
className="tabular-nums"
onClick={(event) => go(event, token)}
>
{token}
</PaginationLink>
</PaginationItem>
) : (
<PaginationItem key={token}>
<PaginationEllipsis />
</PaginationItem>
),
)}
<PaginationItem>
<PaginationNext
href={`#page-${page + 1}`}
aria-disabled={page === TOTAL_PAGES || undefined}
tabIndex={page === TOTAL_PAGES ? -1 : undefined}
className={
page === TOTAL_PAGES ? "pointer-events-none opacity-50" : ""
}
onClick={(event) => go(event, page + 1)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
<form
noValidate
onSubmit={jump}
className="flex flex-col items-center gap-1.5 border-t border-border pt-4"
>
<div className="flex items-center gap-2">
<Label htmlFor={inputId} className="text-muted-foreground">
Go to page
</Label>
<Input
id={inputId}
inputMode="numeric"
autoComplete="off"
placeholder={String(page)}
value={draft}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
onChange={(event) => {
setDraft(event.target.value);
if (error) setError(null);
}}
className="w-16 text-center tabular-nums"
/>
<span className="text-sm text-muted-foreground tabular-nums">
of {TOTAL_PAGES}
</span>
<Button type="submit" variant="secondary">
Go
</Button>
</div>
<p
id={errorId}
role="alert"
className="min-h-5 text-sm text-destructive"
>
{error}
</p>
</form>
</div>
);
}
npx shadcn@latest add @sevenui/component/pagination-07pnpm dlx shadcn@latest add @sevenui/component/pagination-07yarn dlx shadcn@latest add @sevenui/component/pagination-07bunx --bun shadcn@latest add @sevenui/component/pagination-07Chapter 3 of 5 · 9 min
Build rules that pair payouts with invoices by amount, reference, and a tolerance window.
"use client";
import * as React from "react";
import { cn } from "cn";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
} from "@/components/ui/pagination";
const chapters = [
{
title: "Why reconciliation matters",
minutes: 4,
summary:
"How unmatched transactions compound into month-end surprises, and what a clean ledger buys you.",
},
{
title: "Importing bank feeds",
minutes: 7,
summary:
"Connect accounts, map columns from CSV exports, and set the date range the first sync should cover.",
},
{
title: "Matching rules",
minutes: 9,
summary:
"Build rules that pair payouts with invoices by amount, reference, and a tolerance window.",
},
{
title: "Handling exceptions",
minutes: 6,
summary:
"Split partial payments, flag duplicates, and write off bank fees without breaking the audit trail.",
},
{
title: "Closing the period",
minutes: 5,
summary:
"Lock the month, export the reconciliation report, and hand it to your accountant for sign-off.",
},
];
export default function Pagination08() {
const [index, setIndex] = React.useState(2);
const chapter = chapters[index];
const isFirst = index === 0;
const isLast = index === chapters.length - 1;
function go(event: React.MouseEvent<HTMLAnchorElement>, next: number) {
event.preventDefault();
setIndex(Math.min(chapters.length - 1, Math.max(0, next)));
}
return (
<div className="flex w-full max-w-md items-stretch gap-5">
<Pagination aria-label="Chapters" className="mx-0 w-auto">
<PaginationContent className="flex-col gap-1">
<PaginationItem>
<PaginationLink
href={`#chapter-${index}`}
aria-label="Previous chapter"
aria-disabled={isFirst || undefined}
tabIndex={isFirst ? -1 : undefined}
className={cn(isFirst && "pointer-events-none opacity-40")}
onClick={(event) => go(event, index - 1)}
>
<ChevronUpIcon aria-hidden="true" />
</PaginationLink>
</PaginationItem>
{chapters.map((item, position) => (
<PaginationItem key={item.title}>
<PaginationLink
href={`#chapter-${position + 1}`}
aria-label={`Chapter ${position + 1}: ${item.title}`}
isActive={position === index}
className={cn(
"tabular-nums",
position < index && "text-muted-foreground",
)}
onClick={(event) => go(event, position)}
>
{position + 1}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationLink
href={`#chapter-${index + 2}`}
aria-label="Next chapter"
aria-disabled={isLast || undefined}
tabIndex={isLast ? -1 : undefined}
className={cn(isLast && "pointer-events-none opacity-40")}
onClick={(event) => go(event, index + 1)}
>
<ChevronDownIcon aria-hidden="true" />
</PaginationLink>
</PaginationItem>
</PaginationContent>
</Pagination>
<section
aria-live="polite"
className="flex min-w-0 flex-1 flex-col justify-center gap-2 border-l border-border pl-5"
>
<p className="text-xs text-muted-foreground tabular-nums">
Chapter {index + 1} of {chapters.length} · {chapter.minutes} min
</p>
<h3 className="text-base font-semibold text-balance">
{chapter.title}
</h3>
<p className="text-sm text-pretty text-muted-foreground">
{chapter.summary}
</p>
</section>
</div>
);
}
npx shadcn@latest add @sevenui/component/pagination-08pnpm dlx shadcn@latest add @sevenui/component/pagination-08yarn dlx shadcn@latest add @sevenui/component/pagination-08bunx --bun shadcn@latest add @sevenui/component/pagination-08"use client";
import * as React from "react";
import {
AtSignIcon,
ChevronLeftIcon,
ChevronRightIcon,
GitMergeIcon,
MessageSquareIcon,
UserPlusIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
} from "@/components/ui/pagination";
const kindIcon = {
mention: AtSignIcon,
comment: MessageSquareIcon,
merge: GitMergeIcon,
invite: UserPlusIcon,
};
const initialNotifications: {
id: number;
kind: keyof typeof kindIcon;
text: string;
time: string;
unread: boolean;
}[] = [
{ id: 1, kind: "mention", text: "Maya Chen mentioned you in “Q4 pricing review”", time: "4m ago", unread: true },
{ id: 2, kind: "merge", text: "Pull request “Retry webhooks with backoff” was merged", time: "22m ago", unread: true },
{ id: 3, kind: "comment", text: "Diego Alvarez commented on “Onboarding checklist v2”", time: "1h ago", unread: true },
{ id: 4, kind: "invite", text: "Priya Nair joined the Design workspace", time: "3h ago", unread: false },
{ id: 5, kind: "comment", text: "Lena Fischer replied: “Shipping this Thursday works”", time: "5h ago", unread: true },
{ id: 6, kind: "mention", text: "Tom Becker mentioned you in “Incident 2291 postmortem”", time: "Yesterday", unread: false },
{ id: 7, kind: "merge", text: "Pull request “Drop legacy export endpoint” was merged", time: "Yesterday", unread: false },
{ id: 8, kind: "invite", text: "Aiko Tanaka accepted your invite to Growth", time: "Mon", unread: false },
{ id: 9, kind: "comment", text: "Sam Okafor commented on “Churn cohort chart”", time: "Mon", unread: false },
{ id: 10, kind: "mention", text: "Maya Chen mentioned you in “Hiring plan H1”", time: "Sep 18", unread: false },
];
const PAGE_SIZE = 4;
export default function Pagination09() {
const [notifications, setNotifications] =
React.useState(initialNotifications);
const [page, setPage] = React.useState(1);
const pageCount = Math.ceil(notifications.length / PAGE_SIZE);
const visible = notifications.slice(
(page - 1) * PAGE_SIZE,
page * PAGE_SIZE,
);
const unreadCount = notifications.filter((item) => item.unread).length;
function markRead(id: number) {
setNotifications((current) =>
current.map((item) => (item.id === id ? { ...item, unread: false } : item)),
);
}
function goTo(event: React.MouseEvent, next: number) {
event.preventDefault();
if (next >= 1 && next <= pageCount) setPage(next);
}
const atStart = page === 1;
const atEnd = page === pageCount;
return (
<section
aria-labelledby="notifications-title"
className="w-full max-w-sm rounded-xl border bg-popover text-popover-foreground shadow-md"
>
<header className="flex items-center justify-between gap-2 border-b px-4 py-3">
<h2 id="notifications-title" className="text-sm font-medium">
Notifications
{unreadCount > 0 && (
<span className="ml-2 rounded-full bg-primary px-1.5 py-0.5 text-xs text-primary-foreground tabular-nums">
{unreadCount}
<span className="sr-only"> unread</span>
</span>
)}
</h2>
<Button
variant="ghost"
size="sm"
disabled={unreadCount === 0}
onClick={() =>
setNotifications((current) =>
current.map((item) => ({ ...item, unread: false })),
)
}
>
Mark all read
</Button>
</header>
<ul className="min-h-[17rem] divide-y">
{visible.map((item) => {
const Icon = kindIcon[item.kind];
return (
<li key={item.id}>
<button
type="button"
onClick={() => markRead(item.id)}
className="flex w-full items-start gap-3 px-4 py-3 text-left outline-none transition-colors hover:bg-accent/50 focus-visible:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Icon aria-hidden="true" className="size-3.5" />
</span>
<span className="min-w-0 flex-1">
<span
className={
item.unread
? "block text-sm font-medium"
: "block text-sm text-muted-foreground"
}
>
{item.text}
</span>
<span className="text-xs text-muted-foreground">
{item.time}
</span>
</span>
{item.unread && (
<span className="mt-2 size-2 shrink-0 rounded-full bg-primary">
<span className="sr-only">Unread</span>
</span>
)}
</button>
</li>
);
})}
</ul>
<footer className="flex items-center justify-between gap-2 border-t px-2 py-2">
<p
className="pl-2 text-xs text-muted-foreground tabular-nums"
aria-live="polite"
>
Page {page} of {pageCount}
</p>
<Pagination aria-label="Notification pages" className="mx-0 w-auto">
<PaginationContent>
<PaginationItem>
<PaginationLink
href="#"
aria-label="Newer notifications"
aria-disabled={atStart}
tabIndex={atStart ? -1 : undefined}
className={atStart ? "pointer-events-none opacity-50" : ""}
onClick={(event) => goTo(event, page - 1)}
>
<ChevronLeftIcon aria-hidden="true" className="cn-rtl-flip" />
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink
href="#"
aria-label="Older notifications"
aria-disabled={atEnd}
tabIndex={atEnd ? -1 : undefined}
className={atEnd ? "pointer-events-none opacity-50" : ""}
onClick={(event) => goTo(event, page + 1)}
>
<ChevronRightIcon aria-hidden="true" className="cn-rtl-flip" />
</PaginationLink>
</PaginationItem>
</PaginationContent>
</Pagination>
</footer>
</section>
);
}
npx shadcn@latest add @sevenui/component/pagination-09pnpm dlx shadcn@latest add @sevenui/component/pagination-09yarn dlx shadcn@latest add @sevenui/component/pagination-09bunx --bun shadcn@latest add @sevenui/component/pagination-0922 people in Northwind
Maya Chen
maya@northwind.io
Diego Alvarez
diego@northwind.io
Priya Nair
priya@northwind.io
Lena Fischer
lena@northwind.io
"use client";
import * as React from "react";
import { SearchIcon } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const members = [
{ name: "Maya Chen", email: "maya@northwind.io", role: "Owner" },
{ name: "Diego Alvarez", email: "diego@northwind.io", role: "Admin" },
{ name: "Priya Nair", email: "priya@northwind.io", role: "Admin" },
{ name: "Lena Fischer", email: "lena@northwind.io", role: "Member" },
{ name: "Tom Becker", email: "tom@northwind.io", role: "Member" },
{ name: "Aiko Tanaka", email: "aiko@northwind.io", role: "Member" },
{ name: "Sam Okafor", email: "sam@northwind.io", role: "Member" },
{ name: "Hannah Weiss", email: "hannah@northwind.io", role: "Billing" },
{ name: "Rafael Costa", email: "rafael@northwind.io", role: "Member" },
{ name: "Noor Haddad", email: "noor@northwind.io", role: "Member" },
{ name: "Elliot Park", email: "elliot@northwind.io", role: "Guest" },
{ name: "Ines Moreau", email: "ines@northwind.io", role: "Member" },
{ name: "Jonas Berg", email: "jonas@northwind.io", role: "Member" },
{ name: "Chloe Martin", email: "chloe@northwind.io", role: "Guest" },
{ name: "Kwame Mensah", email: "kwame@northwind.io", role: "Member" },
{ name: "Sofia Rossi", email: "sofia@northwind.io", role: "Admin" },
{ name: "Arjun Mehta", email: "arjun@northwind.io", role: "Member" },
{ name: "Olivia Grant", email: "olivia@northwind.io", role: "Member" },
{ name: "Mateo Silva", email: "mateo@northwind.io", role: "Guest" },
{ name: "Yuki Sato", email: "yuki@northwind.io", role: "Member" },
{ name: "Ben Carter", email: "ben@northwind.io", role: "Member" },
{ name: "Zara Ali", email: "zara@northwind.io", role: "Member" },
];
const pageSizes = [
{ value: "4", label: "4" },
{ value: "8", label: "8" },
{ value: "12", label: "12" },
];
function initials(name: string) {
return name
.split(" ")
.map((part) => part[0])
.join("");
}
// Always show the first and last page, plus one neighbor on each side.
function pageRange(current: number, total: number) {
const pages: (number | "ellipsis")[] = [];
for (let number = 1; number <= total; number++) {
if (number === 1 || number === total || Math.abs(number - current) <= 1) {
pages.push(number);
} else if (pages[pages.length - 1] !== "ellipsis") {
pages.push("ellipsis");
}
}
return pages;
}
export default function Pagination10() {
const [query, setQuery] = React.useState("");
const [pageSize, setPageSize] = React.useState("4");
const [page, setPage] = React.useState(1);
const filtered = members.filter((member) =>
`${member.name} ${member.email}`
.toLowerCase()
.includes(query.trim().toLowerCase()),
);
const size = Number(pageSize);
const pageCount = Math.max(1, Math.ceil(filtered.length / size));
const current = Math.min(page, pageCount);
const start = (current - 1) * size;
const visible = filtered.slice(start, start + size);
function goTo(event: React.MouseEvent, next: number) {
event.preventDefault();
if (next >= 1 && next <= pageCount) setPage(next);
}
return (
<section
aria-labelledby="members-title"
className="w-full max-w-xl rounded-xl border bg-card text-card-foreground"
>
<header className="flex flex-col gap-3 border-b p-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 id="members-title" className="font-medium">
Members
</h2>
<p className="text-sm text-muted-foreground">
{members.length} people in Northwind
</p>
</div>
<div className="relative sm:w-56">
<SearchIcon
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
type="search"
aria-label="Search members"
placeholder="Search name or email"
value={query}
onChange={(event) => {
setQuery(event.target.value);
setPage(1);
}}
className="pl-8"
/>
</div>
</header>
{visible.length > 0 ? (
<ul className="divide-y">
{visible.map((member) => (
<li key={member.email} className="flex items-center gap-3 px-4 py-2.5">
<Avatar>
<AvatarFallback>{initials(member.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{member.name}</p>
<p className="truncate text-sm text-muted-foreground">
{member.email}
</p>
</div>
<Badge variant={member.role === "Owner" ? "default" : "outline"}>
{member.role}
</Badge>
</li>
))}
</ul>
) : (
<div className="px-4 py-10 text-center">
<p className="text-sm font-medium">No members match “{query}”</p>
<p className="text-sm text-muted-foreground">
Check the spelling or invite them to the workspace.
</p>
</div>
)}
<footer className="flex flex-col gap-3 border-t px-4 py-3 md:flex-row md:items-center md:justify-between">
<div className="flex items-center justify-between gap-4 md:justify-start">
<div className="flex items-center gap-2">
<span id="rows-per-page-label" className="text-sm text-muted-foreground">
Rows
</span>
<Select
items={pageSizes}
value={pageSize}
onValueChange={(value) => {
if (value) {
setPageSize(value);
setPage(1);
}
}}
>
<SelectTrigger
size="sm"
aria-labelledby="rows-per-page-label"
className="w-16"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizes.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="text-sm text-muted-foreground tabular-nums" aria-live="polite">
{filtered.length === 0
? "0 results"
: `${start + 1}–${start + visible.length} of ${filtered.length}`}
</p>
</div>
<Pagination aria-label="Member list pages" className="mx-0 w-auto">
<PaginationContent className="flex-wrap justify-center">
<PaginationItem>
<PaginationPrevious
href="#"
aria-disabled={current === 1}
tabIndex={current === 1 ? -1 : undefined}
className={current === 1 ? "pointer-events-none opacity-50" : ""}
onClick={(event) => goTo(event, current - 1)}
/>
</PaginationItem>
{pageRange(current, pageCount).map((entry, index) =>
entry === "ellipsis" ? (
// biome-ignore lint/suspicious/noArrayIndexKey: ellipsis position is stable per render
<PaginationItem key={`ellipsis-${index}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={entry}>
<PaginationLink
href="#"
isActive={entry === current}
aria-label={`Page ${entry}`}
onClick={(event) => goTo(event, entry)}
>
{entry}
</PaginationLink>
</PaginationItem>
),
)}
<PaginationItem>
<PaginationNext
href="#"
aria-disabled={current === pageCount}
tabIndex={current === pageCount ? -1 : undefined}
className={
current === pageCount ? "pointer-events-none opacity-50" : ""
}
onClick={(event) => goTo(event, current + 1)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</footer>
</section>
);
}
npx shadcn@latest add @sevenui/component/pagination-10pnpm dlx shadcn@latest add @sevenui/component/pagination-10yarn dlx shadcn@latest add @sevenui/component/pagination-10bunx --bun shadcn@latest add @sevenui/component/pagination-101–4 of 30 products
"use client";
import * as React from "react";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const catalog = [
{ name: "Stoneware mug", price: 28, color: "Sand" },
{ name: "Linen napkins, set of 4", price: 36, color: "Oat" },
{ name: "Walnut serving board", price: 74, color: "Walnut" },
{ name: "Glass carafe", price: 42, color: "Clear" },
{ name: "Enamel pour-over", price: 58, color: "Slate" },
{ name: "Ceramic bowl, large", price: 46, color: "Chalk" },
{ name: "Brass bottle opener", price: 24, color: "Brass" },
{ name: "Wool table runner", price: 68, color: "Moss" },
{ name: "Tumbler, set of 2", price: 32, color: "Smoke" },
{ name: "Olive wood spoon", price: 18, color: "Olive" },
{ name: "Cast iron skillet", price: 95, color: "Black" },
{ name: "Porcelain teapot", price: 64, color: "White" },
{ name: "Cork trivet", price: 16, color: "Natural" },
{ name: "Copper measuring cups", price: 52, color: "Copper" },
{ name: "Waffle dish towel", price: 14, color: "Rust" },
{ name: "Marble mortar", price: 48, color: "Carrara" },
{ name: "Speckled plate", price: 26, color: "Oat" },
{ name: "Bamboo steamer", price: 34, color: "Natural" },
{ name: "Salt cellar", price: 22, color: "Walnut" },
{ name: "Coffee canister", price: 38, color: "Slate" },
{ name: "Pasta bowl, set of 2", price: 54, color: "Sand" },
{ name: "Wine decanter", price: 88, color: "Clear" },
{ name: "Linen apron", price: 44, color: "Moss" },
{ name: "Butter dish", price: 30, color: "Chalk" },
{ name: "Herb scissors", price: 20, color: "Black" },
{ name: "Tea towel, set of 3", price: 27, color: "Rust" },
{ name: "Oak spice rack", price: 62, color: "Oak" },
{ name: "Ceramic vase", price: 56, color: "Smoke" },
{ name: "Pepper mill", price: 40, color: "Walnut" },
{ name: "Glass storage jar", price: 19, color: "Clear" },
];
const sortOptions = [
{ value: "featured", label: "Featured" },
{ value: "price-asc", label: "Price: low to high" },
{ value: "price-desc", label: "Price: high to low" },
];
const PAGE_SIZE = 4;
function pageRange(current: number, total: number) {
const pages: (number | "ellipsis")[] = [];
for (let number = 1; number <= total; number++) {
if (number === 1 || number === total || Math.abs(number - current) <= 1) {
pages.push(number);
} else if (pages[pages.length - 1] !== "ellipsis") {
pages.push("ellipsis");
}
}
return pages;
}
export default function Pagination11() {
const [sort, setSort] = React.useState("featured");
const [page, setPage] = React.useState(1);
const headingRef = React.useRef<HTMLHeadingElement>(null);
const sorted = [...catalog].sort((a, b) => {
if (sort === "price-asc") return a.price - b.price;
if (sort === "price-desc") return b.price - a.price;
return 0;
});
const pageCount = Math.ceil(sorted.length / PAGE_SIZE);
const start = (page - 1) * PAGE_SIZE;
const visible = sorted.slice(start, start + PAGE_SIZE);
function goTo(event: React.MouseEvent, next: number) {
event.preventDefault();
if (next < 1 || next > pageCount || next === page) return;
setPage(next);
// Move focus to the results heading so screen reader and keyboard
// users land on the new products instead of the pager.
headingRef.current?.focus();
}
return (
<section aria-labelledby="catalog-title" className="w-full max-w-2xl">
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
<div>
<h2
id="catalog-title"
ref={headingRef}
tabIndex={-1}
className="rounded-sm text-lg font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Kitchen & dining
</h2>
<p className="text-sm text-muted-foreground" aria-live="polite">
{start + 1}–{start + visible.length} of {catalog.length} products
</p>
</div>
<Select
items={sortOptions}
value={sort}
onValueChange={(value) => {
if (value) {
setSort(value);
setPage(1);
}
}}
>
<SelectTrigger aria-label="Sort products" className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<ul className="grid grid-cols-2 gap-x-3 gap-y-5 sm:grid-cols-4">
{visible.map((product) => (
<li key={product.name} className="group">
<a
href={`#${product.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`}
className="block rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<div className="aspect-[4/5] overflow-hidden rounded-lg bg-muted">
<img
src="/placeholder.svg"
alt=""
className="size-full object-cover transition-transform duration-300 ease-out group-hover:scale-105"
/>
</div>
<p className="mt-2 truncate text-sm font-medium">
{product.name}
</p>
<p className="flex justify-between text-sm text-muted-foreground">
<span>{product.color}</span>
<span className="font-medium text-foreground tabular-nums">
${product.price}
</span>
</p>
</a>
</li>
))}
</ul>
<Pagination aria-label="Product pages" className="mt-8 border-t pt-4">
<PaginationContent className="w-full">
<PaginationItem className="mr-auto">
<PaginationPrevious
href="#"
aria-disabled={page === 1}
tabIndex={page === 1 ? -1 : undefined}
className={page === 1 ? "pointer-events-none opacity-50" : ""}
onClick={(event) => goTo(event, page - 1)}
/>
</PaginationItem>
<li className="px-2 text-sm text-muted-foreground tabular-nums sm:hidden">
Page {page} of {pageCount}
</li>
{pageRange(page, pageCount).map((entry, index) =>
entry === "ellipsis" ? (
// biome-ignore lint/suspicious/noArrayIndexKey: ellipsis position is stable per render
<PaginationItem key={`ellipsis-${index}`} className="hidden sm:block">
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={entry} className="hidden sm:block">
<PaginationLink
href="#"
isActive={entry === page}
aria-label={`Page ${entry}`}
onClick={(event) => goTo(event, entry)}
>
{entry}
</PaginationLink>
</PaginationItem>
),
)}
<PaginationItem className="ml-auto">
<PaginationNext
href="#"
aria-disabled={page === pageCount}
tabIndex={page === pageCount ? -1 : undefined}
className={
page === pageCount ? "pointer-events-none opacity-50" : ""
}
onClick={(event) => goTo(event, page + 1)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</section>
);
}
npx shadcn@latest add @sevenui/component/pagination-11pnpm dlx shadcn@latest add @sevenui/component/pagination-11yarn dlx shadcn@latest add @sevenui/component/pagination-11bunx --bun shadcn@latest add @sevenui/component/pagination-11v2.6.0 · September 22, 2026
Release 1 of 5
"use client";
import * as React from "react";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
} from "@/components/ui/pagination";
type Change = { kind: "New" | "Improved" | "Fixed"; text: string };
// Newest release first, one release per page.
const releases: {
version: string;
date: string;
title: string;
changes: Change[];
}[] = [
{
version: "2.6.0",
date: "September 22, 2026",
title: "Saved views",
changes: [
{ kind: "New", text: "Save any filter and sort combination as a named view." },
{ kind: "New", text: "Share views with your workspace from the view menu." },
{ kind: "Fixed", text: "Column widths no longer reset after a refresh." },
],
},
{
version: "2.5.0",
date: "September 8, 2026",
title: "Bulk editing",
changes: [
{ kind: "New", text: "Select up to 500 rows and edit a field in one step." },
{ kind: "Improved", text: "Undo now covers bulk changes for 30 seconds." },
],
},
{
version: "2.4.2",
date: "August 27, 2026",
title: "Faster exports",
changes: [
{ kind: "Improved", text: "CSV exports of 100k rows finish 4× faster." },
{ kind: "Fixed", text: "Exported dates now respect your workspace time zone." },
],
},
{
version: "2.4.0",
date: "August 12, 2026",
title: "Keyboard navigation",
changes: [
{ kind: "New", text: "Move between cells with arrow keys and edit with Enter." },
{ kind: "Improved", text: "Focus rings are visible in high contrast mode." },
{ kind: "Fixed", text: "Escape closes the cell editor without saving." },
],
},
{
version: "2.3.0",
date: "July 29, 2026",
title: "Audit history",
changes: [
{ kind: "New", text: "See who changed a record and restore any earlier value." },
],
},
];
const kindVariant: Record<Change["kind"], "default" | "secondary" | "outline"> = {
New: "default",
Improved: "secondary",
Fixed: "outline",
};
export default function Pagination12() {
const [index, setIndex] = React.useState(0);
const release = releases[index];
const newer = releases[index - 1];
const older = releases[index + 1];
function goTo(event: React.MouseEvent, next: number) {
event.preventDefault();
if (next >= 0 && next < releases.length) setIndex(next);
}
return (
<article
aria-labelledby="release-title"
className="w-full max-w-lg"
>
<header className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<h2 id="release-title" className="text-xl font-semibold text-balance">
{release.title}
</h2>
<p className="text-sm text-muted-foreground tabular-nums">
v{release.version} · {release.date}
</p>
</header>
<ul className="mt-4 flex min-h-36 flex-col gap-3" aria-live="polite">
{release.changes.map((change) => (
<li key={change.text} className="flex items-start gap-3 text-sm">
<Badge variant={kindVariant[change.kind]} className="mt-px w-18">
{change.kind}
</Badge>
<span className="leading-relaxed">{change.text}</span>
</li>
))}
</ul>
<Pagination aria-label="Release notes" className="mt-6 border-t pt-4">
<PaginationContent className="grid w-full grid-cols-2 gap-3">
<PaginationItem>
{newer ? (
<PaginationLink
href="#"
size="default"
onClick={(event) => goTo(event, index - 1)}
className="h-auto w-full flex-col items-start gap-0.5 border border-border px-3 py-2.5 text-left whitespace-normal"
>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<ArrowLeftIcon aria-hidden="true" className="size-3 cn-rtl-flip" />
Newer · v{newer.version}
</span>
<span className="line-clamp-1 font-medium">{newer.title}</span>
</PaginationLink>
) : (
<p className="px-3 py-2.5 text-xs text-muted-foreground">
You're on the latest release.
</p>
)}
</PaginationItem>
<PaginationItem>
{older ? (
<PaginationLink
href="#"
size="default"
onClick={(event) => goTo(event, index + 1)}
className="h-auto w-full flex-col items-end gap-0.5 border border-border px-3 py-2.5 text-right whitespace-normal"
>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
Older · v{older.version}
<ArrowRightIcon aria-hidden="true" className="size-3 cn-rtl-flip" />
</span>
<span className="line-clamp-1 font-medium">{older.title}</span>
</PaginationLink>
) : (
<p className="px-3 py-2.5 text-right text-xs text-muted-foreground">
That's every release since 2.3.
</p>
)}
</PaginationItem>
</PaginationContent>
</Pagination>
<p className="mt-3 text-center text-xs text-muted-foreground tabular-nums">
Release {index + 1} of {releases.length}
</p>
</article>
);
}
npx shadcn@latest add @sevenui/component/pagination-12pnpm dlx shadcn@latest add @sevenui/component/pagination-12yarn dlx shadcn@latest add @sevenui/component/pagination-12bunx --bun shadcn@latest add @sevenui/component/pagination-1214 files
"use client";
import * as React from "react";
import {
CheckIcon,
DownloadIcon,
FileImageIcon,
FileSpreadsheetIcon,
FileTextIcon,
FileVideoIcon,
Trash2Icon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
const typeIcon = {
doc: FileTextIcon,
image: FileImageIcon,
sheet: FileSpreadsheetIcon,
video: FileVideoIcon,
};
const initialFiles: {
name: string;
type: keyof typeof typeIcon;
size: string;
modified: string;
}[] = [
{ name: "Brand guidelines 2026.pdf", type: "doc", size: "8.2 MB", modified: "Sep 22" },
{ name: "Homepage hero.png", type: "image", size: "2.4 MB", modified: "Sep 21" },
{ name: "Q3 revenue.xlsx", type: "sheet", size: "640 KB", modified: "Sep 20" },
{ name: "Product walkthrough.mp4", type: "video", size: "184 MB", modified: "Sep 18" },
{ name: "Launch checklist.docx", type: "doc", size: "96 KB", modified: "Sep 17" },
{ name: "Team offsite photo.jpg", type: "image", size: "5.1 MB", modified: "Sep 15" },
{ name: "Churn by cohort.xlsx", type: "sheet", size: "1.2 MB", modified: "Sep 12" },
{ name: "Press kit.pdf", type: "doc", size: "12.8 MB", modified: "Sep 10" },
{ name: "Onboarding screens.png", type: "image", size: "3.7 MB", modified: "Sep 8" },
{ name: "Customer interview 04.mp4", type: "video", size: "322 MB", modified: "Sep 5" },
{ name: "Pricing experiment.xlsx", type: "sheet", size: "410 KB", modified: "Sep 2" },
{ name: "Roadmap H2.pdf", type: "doc", size: "2.2 MB", modified: "Aug 29" },
{ name: "App icon set.png", type: "image", size: "900 KB", modified: "Aug 27" },
{ name: "Support macros.docx", type: "doc", size: "54 KB", modified: "Aug 24" },
];
const PAGE_SIZE = 5;
export default function Pagination13() {
const [files, setFiles] = React.useState(initialFiles);
const [selected, setSelected] = React.useState<Set<string>>(new Set());
const [page, setPage] = React.useState(1);
const [downloaded, setDownloaded] = React.useState(false);
const downloadTimer = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
);
React.useEffect(() => {
return () => {
if (downloadTimer.current) clearTimeout(downloadTimer.current);
};
}, []);
const pageCount = Math.max(1, Math.ceil(files.length / PAGE_SIZE));
const current = Math.min(page, pageCount);
const visible = files.slice((current - 1) * PAGE_SIZE, current * PAGE_SIZE);
const selectedOnPage = visible.filter((file) => selected.has(file.name));
const allOnPage = visible.length > 0 && selectedOnPage.length === visible.length;
function toggle(name: string, checked: boolean) {
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(name);
else next.delete(name);
return next;
});
}
function togglePage(checked: boolean) {
setSelected((prev) => {
const next = new Set(prev);
for (const file of visible) {
if (checked) next.add(file.name);
else next.delete(file.name);
}
return next;
});
}
// Simulates handing the selection to the browser as a zip, then confirms
// briefly on the button before it returns to its idle label.
function downloadSelected() {
if (downloadTimer.current) clearTimeout(downloadTimer.current);
setDownloaded(true);
downloadTimer.current = setTimeout(() => setDownloaded(false), 1500);
}
function deleteSelected() {
setFiles((prev) => prev.filter((file) => !selected.has(file.name)));
setSelected(new Set());
}
function goTo(event: React.MouseEvent, next: number) {
event.preventDefault();
if (next >= 1 && next <= pageCount) setPage(next);
}
// Count selections that live on other pages so they aren't forgotten.
const elsewhere = selected.size - selectedOnPage.length;
return (
<section
aria-label="Marketing assets"
className="w-full max-w-xl overflow-hidden rounded-xl border bg-card text-card-foreground"
>
<header className="flex min-h-14 items-center justify-between gap-2 border-b px-4 py-2">
{selected.size > 0 ? (
<>
<p className="text-sm" aria-live="polite">
<span className="font-medium tabular-nums">{selected.size}</span>{" "}
selected
{elsewhere > 0 && (
<span className="text-muted-foreground">
{" "}
· {elsewhere} on other pages
</span>
)}
</p>
<div className="flex gap-1">
<Button variant="outline" size="sm" onClick={downloadSelected}>
{downloaded ? (
<CheckIcon aria-hidden="true" data-icon="inline-start" />
) : (
<DownloadIcon aria-hidden="true" data-icon="inline-start" />
)}
<span className="hidden sm:inline">
{downloaded ? "Downloaded" : "Download"}
</span>
<span className="sr-only sm:hidden">
{downloaded ? "Downloaded" : "Download selected"}
</span>
</Button>
<Button variant="destructive" size="sm" onClick={deleteSelected}>
<Trash2Icon aria-hidden="true" data-icon="inline-start" />
<span className="hidden sm:inline">Delete</span>
<span className="sr-only sm:hidden">Delete selected</span>
</Button>
</div>
</>
) : (
<>
<h2 className="font-medium">
Marketing assets
</h2>
<p className="text-sm text-muted-foreground tabular-nums">
{files.length} files
</p>
</>
)}
</header>
<div className="flex items-center gap-3 border-b bg-muted/40 px-4 py-2 text-xs font-medium text-muted-foreground">
<Checkbox
aria-label="Select all files on this page"
checked={allOnPage}
indeterminate={selectedOnPage.length > 0 && !allOnPage}
onCheckedChange={(checked) => togglePage(checked)}
disabled={visible.length === 0}
/>
<span className="flex-1">Name</span>
<span className="hidden w-16 text-right sm:block">Size</span>
<span className="w-14 text-right">Modified</span>
</div>
{visible.length > 0 ? (
<ul className="divide-y">
{visible.map((file) => {
const Icon = typeIcon[file.type];
const isSelected = selected.has(file.name);
return (
<li
key={file.name}
data-selected={isSelected || undefined}
className="flex items-center gap-3 px-4 py-2.5 text-sm data-selected:bg-accent/60"
>
<Checkbox
aria-label={`Select ${file.name}`}
checked={isSelected}
onCheckedChange={(checked) => toggle(file.name, checked)}
/>
<Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{file.name}</span>
<span className="hidden w-16 text-right text-muted-foreground tabular-nums sm:block">
{file.size}
</span>
<span className="w-14 text-right text-muted-foreground tabular-nums">
{file.modified}
</span>
</li>
);
})}
</ul>
) : (
<p className="px-4 py-10 text-center text-sm text-muted-foreground">
This folder is empty.
</p>
)}
<footer className="border-t px-2 py-2">
<Pagination aria-label="File pages">
<PaginationContent className="w-full">
<PaginationItem className="mr-auto">
<PaginationPrevious
href="#"
aria-disabled={current === 1}
tabIndex={current === 1 ? -1 : undefined}
className={current === 1 ? "pointer-events-none opacity-50" : ""}
onClick={(event) => goTo(event, current - 1)}
/>
</PaginationItem>
{Array.from({ length: pageCount }, (_, index) => index + 1).map(
(number) => (
<PaginationItem key={number}>
<PaginationLink
href="#"
size="icon-sm"
isActive={number === current}
aria-label={`Page ${number}`}
onClick={(event) => goTo(event, number)}
>
{number}
</PaginationLink>
</PaginationItem>
),
)}
<PaginationItem className="ml-auto">
<PaginationNext
href="#"
aria-disabled={current === pageCount}
tabIndex={current === pageCount ? -1 : undefined}
className={
current === pageCount ? "pointer-events-none opacity-50" : ""
}
onClick={(event) => goTo(event, current + 1)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</footer>
</section>
);
}
npx shadcn@latest add @sevenui/component/pagination-13pnpm dlx shadcn@latest add @sevenui/component/pagination-13yarn dlx shadcn@latest add @sevenui/component/pagination-13bunx --bun shadcn@latest add @sevenui/component/pagination-13Live mode · sk_live_…4f2c
"use client";
import * as React from "react";
import { ArrowUpToLineIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
// Newest first. A real API would hand back `next_cursor` instead of offsets,
// so the UI never shows a total page count.
const requests = [
{ id: "req_9f2a", method: "POST", path: "/v1/charges", status: 201, ms: 182, time: "14:02:51" },
{ id: "req_9f29", method: "GET", path: "/v1/customers/cus_48Q", status: 200, ms: 41, time: "14:02:48" },
{ id: "req_9f28", method: "POST", path: "/v1/refunds", status: 402, ms: 96, time: "14:02:30" },
{ id: "req_9f27", method: "GET", path: "/v1/invoices?limit=20", status: 200, ms: 64, time: "14:02:12" },
{ id: "req_9f26", method: "DELETE", path: "/v1/webhooks/wh_12", status: 204, ms: 58, time: "14:01:57" },
{ id: "req_9f25", method: "POST", path: "/v1/charges", status: 500, ms: 1204, time: "14:01:40" },
{ id: "req_9f24", method: "PATCH", path: "/v1/customers/cus_48Q", status: 200, ms: 77, time: "14:01:22" },
{ id: "req_9f23", method: "GET", path: "/v1/balance", status: 401, ms: 12, time: "14:01:05" },
{ id: "req_9f22", method: "POST", path: "/v1/payment_intents", status: 200, ms: 211, time: "14:00:49" },
{ id: "req_9f21", method: "GET", path: "/v1/charges/ch_3Nx", status: 404, ms: 18, time: "14:00:31" },
{ id: "req_9f20", method: "POST", path: "/v1/subscriptions", status: 201, ms: 305, time: "14:00:14" },
{ id: "req_9f1f", method: "GET", path: "/v1/events?type=charge", status: 200, ms: 88, time: "13:59:58" },
{ id: "req_9f1e", method: "POST", path: "/v1/charges", status: 429, ms: 9, time: "13:59:41" },
{ id: "req_9f1d", method: "GET", path: "/v1/products", status: 200, ms: 52, time: "13:59:20" },
{ id: "req_9f1c", method: "POST", path: "/v1/refunds", status: 200, ms: 140, time: "13:59:02" },
{ id: "req_9f1b", method: "GET", path: "/v1/prices", status: 503, ms: 3001, time: "13:58:44" },
];
const filters = [
{ value: "all", label: "All" },
{ value: "success", label: "2xx" },
{ value: "client", label: "4xx" },
{ value: "server", label: "5xx" },
];
const PAGE_SIZE = 5;
function matches(filter: string, status: number) {
if (filter === "success") return status < 300;
if (filter === "client") return status >= 400 && status < 500;
if (filter === "server") return status >= 500;
return true;
}
function statusClass(status: number) {
if (status >= 500) return "bg-destructive/10 text-destructive";
if (status >= 400) return "bg-warning/20 text-foreground";
return "bg-success/15 text-foreground";
}
export default function Pagination14() {
const [filter, setFilter] = React.useState("all");
// Stack of cursors: the last entry is where the current page starts.
const [cursors, setCursors] = React.useState<number[]>([0]);
const results = requests.filter((request) => matches(filter, request.status));
const cursor = cursors[cursors.length - 1];
const visible = results.slice(cursor, cursor + PAGE_SIZE);
const hasNewer = cursors.length > 1;
const hasOlder = cursor + PAGE_SIZE < results.length;
function newer(event: React.MouseEvent) {
event.preventDefault();
if (hasNewer) setCursors((stack) => stack.slice(0, -1));
}
function older(event: React.MouseEvent) {
event.preventDefault();
if (hasOlder) setCursors((stack) => [...stack, cursor + PAGE_SIZE]);
}
return (
<section
aria-labelledby="request-log-title"
className="w-full max-w-2xl rounded-xl border bg-card text-card-foreground"
>
<header className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
<div>
<h2 id="request-log-title" className="font-medium">
API requests
</h2>
<p className="text-sm text-muted-foreground">
Live mode · <span className="font-mono text-xs">sk_live_…4f2c</span>
</p>
</div>
<ToggleGroup
aria-label="Filter by status"
variant="outline"
size="sm"
spacing={0}
value={[filter]}
onValueChange={(next) => {
if (next.length > 0) {
setFilter(next[0]);
setCursors([0]);
}
}}
>
{filters.map((item) => (
<ToggleGroupItem
key={item.value}
value={item.value}
className="font-mono text-xs aria-pressed:bg-accent aria-pressed:text-accent-foreground"
>
{item.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</header>
{visible.length > 0 ? (
<ul className="divide-y font-mono text-xs">
{visible.map((request) => (
<li
key={request.id}
className="grid grid-cols-[3rem_1fr_auto] items-center gap-x-3 px-4 py-2.5 sm:grid-cols-[3rem_4rem_1fr_auto_auto]"
>
<span
className={`rounded px-1.5 py-0.5 text-center font-medium tabular-nums ${statusClass(request.status)}`}
>
{request.status}
</span>
<span className="hidden text-muted-foreground sm:block">
{request.method}
</span>
<span className="truncate">
<span className="text-muted-foreground sm:hidden">
{request.method}{" "}
</span>
{request.path}
</span>
<span className="hidden text-right text-muted-foreground tabular-nums sm:block">
{request.ms} ms
</span>
<time className="text-right text-muted-foreground tabular-nums">
{request.time}
</time>
</li>
))}
</ul>
) : (
<p className="px-4 py-10 text-center text-sm text-muted-foreground">
No requests with this status in the last hour.
</p>
)}
<footer className="flex items-center justify-between gap-2 border-t px-2 py-2">
{hasNewer ? (
<Button variant="ghost" size="sm" onClick={() => setCursors([0])}>
<ArrowUpToLineIcon aria-hidden="true" data-icon="inline-start" />
Latest
</Button>
) : (
<p className="pl-2 text-xs text-muted-foreground">
Showing newest requests
</p>
)}
<Pagination aria-label="Request log pages" className="mx-0 w-auto">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
text="Newer"
aria-label="Newer requests"
aria-disabled={!hasNewer}
tabIndex={hasNewer ? undefined : -1}
className={hasNewer ? "" : "pointer-events-none opacity-50"}
onClick={newer}
/>
</PaginationItem>
<PaginationItem>
<PaginationNext
href="#"
text="Older"
aria-label="Older requests"
aria-disabled={!hasOlder}
tabIndex={hasOlder ? undefined : -1}
className={hasOlder ? "" : "pointer-events-none opacity-50"}
onClick={older}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</footer>
</section>
);
}
npx shadcn@latest add @sevenui/component/pagination-14pnpm dlx shadcn@latest add @sevenui/component/pagination-14yarn dlx shadcn@latest add @sevenui/component/pagination-14bunx --bun shadcn@latest add @sevenui/component/pagination-14