diff --git a/packages/propel/src/components/pagination/index.tsx b/packages/propel/src/components/pagination/index.tsx
new file mode 100644
index 00000000..c2982d82
--- /dev/null
+++ b/packages/propel/src/components/pagination/index.tsx
@@ -0,0 +1,327 @@
+import { cva, cx } from "class-variance-authority";
+import { ArrowLeft, ArrowRight, ChevronDown, LoaderCircle, MoreHorizontal } from "lucide-react";
+import * as React from "react";
+import { Dropdown, DropdownContent, DropdownItem, DropdownTrigger } from "../dropdown/index";
+
+// Pagination is a single composite navigation control rather than a variant matrix:
+// the Figma "variant" axis (All pages visible / Near start / Middle / Near end) is
+// purely a function of where the current page sits within the total, so it's derived
+// at render time from `page`/`pageCount` — never a prop. What designers can toggle is
+// genuinely additive: an optional per-page selector and an optional range label.
+//
+// Tokens (Figma node 4762-503):
+// - page-number button: 24px square, radius/sm (4px), text/13, transparent bg that
+// fills to `layer-transparent-hover` on hover and `layer-transparent-active` when
+// it is the current page; disabled/loading dim to the placeholder/disabled colors.
+// - prev/next: 24px square icon buttons, radius/md (6px), 16px arrows. The arrows are
+// directional, so they mirror in RTL via `rtl:-scale-x-100`.
+// - ellipsis: a non-interactive 24px slot holding a 14px more-horizontal glyph.
+// - per-page selector: a `layer-3` pill, 24px tall, radius/md, "50" + chevron-down,
+// followed by "per page" tertiary text. The pill is the trigger for a propel
+// Dropdown (single-select) whose menu lists the page-size options; picking one
+// reports it through `pageSize.onChange`.
+
+// Shared 24px slot used by page numbers, the prev/next buttons and the ellipsis.
+// 24px tall with a 24px minimum width so single digits stay square (per Figma),
+// but the slot grows for wider content — multi-digit page numbers like `100` get
+// their own width plus horizontal padding rather than clipping a fixed square.
+// `radius/sm` for page numbers, `radius/md` for the arrow buttons.
+const slotBase = cx(
+ "inline-flex h-6 w-auto min-w-6 shrink-0 items-center justify-center px-1",
+ "text-13 text-primary outline-none",
+);
+
+const pageButtonVariants = cva(
+ cx(
+ slotBase,
+ "rounded-sm bg-layer-transparent",
+ "hover:bg-layer-transparent-hover",
+ "focus-visible:ring-2 focus-visible:ring-accent-strong",
+ "disabled:pointer-events-none disabled:text-disabled",
+ ),
+ {
+ variants: {
+ // The current page reads as pressed: it sits on the `transparent-active` fill
+ // (Figma "Selected") and is not interactive. It's marked `disabled` to block
+ // re-navigation, but the Figma "Selected" state keeps `text/primary` — so it
+ // must override the `disabled:text-disabled` dim that the base applies.
+ current: {
+ true: "bg-layer-transparent-active disabled:text-primary",
+ false: "",
+ },
+ },
+ },
+);
+
+const arrowButtonVariants = cva(
+ cx(
+ slotBase,
+ "rounded-md bg-layer-transparent text-icon-secondary",
+ "hover:bg-layer-transparent-hover",
+ "focus-visible:ring-2 focus-visible:ring-accent-strong",
+ "disabled:pointer-events-none disabled:text-icon-disabled",
+ // Prev/next arrows are directional — mirror them under RTL so "previous" always
+ // points toward the start of the run.
+ "[&_svg]:size-4 [&_svg]:shrink-0 rtl:[&_svg]:-scale-x-100",
+ ),
+);
+
+// The per-page selector trigger (Figma `4762-503`): a `layer-3` pill, 24px tall,
+// `radius/md`, holding the current size + a chevron-down. It's the trigger for the
+// page-size Dropdown, so it gets a focus ring and rotates its chevron while the menu
+// is open.
+const perPageTriggerClass = cx(
+ "inline-flex h-6 min-w-10 cursor-default items-center justify-center gap-1 rounded-md px-2",
+ "bg-layer-3 text-13 font-medium text-secondary outline-none",
+ "hover:bg-layer-3-hover",
+ "focus-visible:ring-2 focus-visible:ring-accent-strong",
+ "[&_svg]:size-3.5 [&_svg]:shrink-0 [&_svg]:text-icon-secondary",
+ "[&[data-popup-open]_svg]:rotate-180",
+);
+
+/** A non-interactive gap marker between distant page numbers. */
+function PaginationEllipsis() {
+ return (
+
+
+
+ );
+}
+
+// Builds the sequence of visible page tokens. Always shows the first and last page;
+// shows up to one neighbour either side of the current page; inserts an ellipsis
+// only where the run skips 2+ pages. A gap of exactly one page renders that page
+// number instead, since a `…` standing in for a single page just hides a reachable
+// page. This reproduces the four Figma layouts:
+// 1 2 3 4 (all visible)
+// 1 2 3 … 100 (near start)
+// 1 … 44 45 46 … 100 (middle)
+// 1 … 98 99 100 (near end)
+type PageToken = number | "ellipsis-start" | "ellipsis-end";
+
+function buildPageTokens(page: number, pageCount: number): PageToken[] {
+ // Few enough pages to show them all.
+ if (pageCount <= 7) {
+ return Array.from({ length: pageCount }, (_, i) => i + 1);
+ }
+ // A window of three consecutive pages around the current one (current ± 1). When
+ // that window butts up against an end, it shifts inward so the end side always
+ // shows three numbers — reproducing Figma's `1 2 3 … 100` / `1 … 98 99 100`.
+ let windowStart = page - 1;
+ let windowEnd = page + 1;
+ if (windowStart < 2) {
+ // Near the start: the run abuts the `1` anchor, so show `1 2 3 … last`.
+ windowStart = 2;
+ windowEnd = 3;
+ }
+ if (windowEnd > pageCount - 1) {
+ // Near the end: the run abuts the `last` anchor, so show `1 … last-2 last-1 last`.
+ windowEnd = pageCount - 1;
+ windowStart = pageCount - 2;
+ }
+ const tokens: PageToken[] = [1];
+ // Bridge the gap between the `1` anchor and the window. An ellipsis is only worth
+ // it when 2+ pages are hidden; a lone skipped page (here, page 2) is rendered as
+ // its own number so it isn't buried under `…` — `1 2 3 4 5 …` instead of `1 … 3 …`.
+ if (windowStart === 3) tokens.push(2);
+ else if (windowStart > 3) tokens.push("ellipsis-start");
+ for (let p = windowStart; p <= windowEnd; p++) tokens.push(p);
+ // Likewise on the trailing side: a single skipped page (pageCount - 1) is shown
+ // rather than hidden behind a trailing ellipsis.
+ if (windowEnd === pageCount - 2) tokens.push(pageCount - 1);
+ else if (windowEnd < pageCount - 2) tokens.push("ellipsis-end");
+ tokens.push(pageCount);
+ return tokens;
+}
+
+export type PaginationLabels = {
+ /** Accessible name for the `