ruler-components 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/AGENTS.md +9 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +21 -0
  4. package/README.md +45 -0
  5. package/app/docs/[slug]/page.tsx +147 -0
  6. package/app/docs/layout.tsx +26 -0
  7. package/app/docs/page.tsx +148 -0
  8. package/app/favicon.ico +0 -0
  9. package/app/globals.css +168 -0
  10. package/app/icon.png +0 -0
  11. package/app/layout.tsx +90 -0
  12. package/app/opengraph-image.tsx +10 -0
  13. package/app/page.tsx +34 -0
  14. package/app/twitter-image.tsx +10 -0
  15. package/components/docs/accordion-doc-page.tsx +352 -0
  16. package/components/docs/alert-doc-page.tsx +293 -0
  17. package/components/docs/allinput-doc-page.tsx +183 -0
  18. package/components/docs/avatar-doc-page.tsx +424 -0
  19. package/components/docs/badge-doc-page.tsx +363 -0
  20. package/components/docs/button-doc-page.tsx +406 -0
  21. package/components/docs/card-doc-page.tsx +364 -0
  22. package/components/docs/checkbox-doc-page.tsx +277 -0
  23. package/components/docs/colors-doc-page.tsx +430 -0
  24. package/components/docs/dialog-doc-page.tsx +364 -0
  25. package/components/docs/docs-footer.tsx +11 -0
  26. package/components/docs/docs-mobile-menu-bar.tsx +22 -0
  27. package/components/docs/docs-mobile-nav-context.tsx +27 -0
  28. package/components/docs/docs-page.tsx +263 -0
  29. package/components/docs/docs-search.tsx +303 -0
  30. package/components/docs/docs-sidebar.tsx +165 -0
  31. package/components/docs/docs-topbar.tsx +32 -0
  32. package/components/docs/dropdown-doc-page.tsx +522 -0
  33. package/components/docs/getting-started-doc-page.tsx +205 -0
  34. package/components/docs/label-doc-page.tsx +173 -0
  35. package/components/docs/loaders-doc-page.tsx +244 -0
  36. package/components/docs/menu-doc-page.tsx +373 -0
  37. package/components/docs/modal-doc-page.tsx +436 -0
  38. package/components/docs/next-steps.tsx +53 -0
  39. package/components/docs/pagination-doc-page.tsx +316 -0
  40. package/components/docs/progress-doc-page.tsx +387 -0
  41. package/components/docs/radio-doc-page.tsx +270 -0
  42. package/components/docs/skeleton-doc-page.tsx +230 -0
  43. package/components/docs/stepper-doc-page.tsx +309 -0
  44. package/components/docs/switch-doc-page.tsx +264 -0
  45. package/components/docs/table-doc-page.tsx +689 -0
  46. package/components/docs/tabs-doc-page.tsx +359 -0
  47. package/components/docs/theming-doc-page.tsx +243 -0
  48. package/components/docs/tooltip-doc-page.tsx +357 -0
  49. package/components/docs/usage-section.tsx +21 -0
  50. package/components/icons/discord.tsx +9 -0
  51. package/components/icons/github.tsx +9 -0
  52. package/components/icons/x.tsx +9 -0
  53. package/components/installation-section.tsx +34 -0
  54. package/components/landing/feature-highlights.tsx +105 -0
  55. package/components/landing/footer-strip.tsx +45 -0
  56. package/components/landing/hero-card.tsx +14 -0
  57. package/components/landing/hero-spotlight.tsx +126 -0
  58. package/components/landing/hero.tsx +64 -0
  59. package/components/landing/navbar.tsx +44 -0
  60. package/components/landing/quick-start-section.tsx +59 -0
  61. package/components/landing/site-footer.tsx +55 -0
  62. package/components/theme-provider.tsx +60 -0
  63. package/components/ui/accordion.tsx +174 -0
  64. package/components/ui/alert.tsx +103 -0
  65. package/components/ui/allinput.tsx +210 -0
  66. package/components/ui/avatar.tsx +179 -0
  67. package/components/ui/badge.tsx +52 -0
  68. package/components/ui/button.tsx +75 -0
  69. package/components/ui/card.tsx +105 -0
  70. package/components/ui/checkbox.tsx +130 -0
  71. package/components/ui/code-block.tsx +60 -0
  72. package/components/ui/data-table.tsx +235 -0
  73. package/components/ui/dialog.tsx +173 -0
  74. package/components/ui/dropdown.tsx +78 -0
  75. package/components/ui/feature-card.tsx +38 -0
  76. package/components/ui/input.tsx +44 -0
  77. package/components/ui/label.tsx +110 -0
  78. package/components/ui/loaders.tsx +139 -0
  79. package/components/ui/menu.tsx +118 -0
  80. package/components/ui/modal.tsx +399 -0
  81. package/components/ui/package-manager-tabs.tsx +40 -0
  82. package/components/ui/pagination.tsx +200 -0
  83. package/components/ui/progress.tsx +134 -0
  84. package/components/ui/radio.tsx +262 -0
  85. package/components/ui/skeleton.tsx +102 -0
  86. package/components/ui/steppers.tsx +309 -0
  87. package/components/ui/switch.tsx +153 -0
  88. package/components/ui/table.tsx +58 -0
  89. package/components/ui/tabs.tsx +166 -0
  90. package/components/ui/theme-toggle.tsx +60 -0
  91. package/components/ui/tooltip.tsx +238 -0
  92. package/data/color-palette.ts +35 -0
  93. package/data/docs-nav.ts +84 -0
  94. package/eslint.config.mjs +18 -0
  95. package/index.ts +8 -0
  96. package/lib/cn.ts +4 -0
  97. package/lib/color.ts +85 -0
  98. package/lib/og-image.tsx +138 -0
  99. package/lib/theme.ts +33 -0
  100. package/next.config.ts +7 -0
  101. package/package.json +30 -0
  102. package/postcss.config.mjs +7 -0
  103. package/public/dark.png +0 -0
  104. package/public/dark1.png +0 -0
  105. package/public/dark2.png +0 -0
  106. package/public/file.svg +1 -0
  107. package/public/globe.svg +1 -0
  108. package/public/light.png +0 -0
  109. package/public/light1.png +0 -0
  110. package/public/next.svg +1 -0
  111. package/public/vercel.svg +1 -0
  112. package/public/video/hero-demo.mp4 +0 -0
  113. package/public/window.svg +1 -0
  114. package/tsconfig.json +34 -0
@@ -0,0 +1,399 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { cva, type VariantProps } from "class-variance-authority";
6
+ import { X } from "lucide-react";
7
+ import { cn } from "@/lib/cn";
8
+
9
+ const modalContentVariants = cva(
10
+ [
11
+ "relative z-10 flex w-full flex-col overflow-hidden rounded-2xl",
12
+ "border border-zinc-200/90 bg-white text-zinc-900",
13
+ "shadow-[0_20px_60px_rgba(0,0,0,0.12),0_0_0_1px_rgba(0,0,0,0.04)]",
14
+ "dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100",
15
+ "dark:shadow-[0_20px_60px_rgba(0,0,0,0.45),0_0_0_1px_rgba(255,255,255,0.06)]",
16
+ "transition-[opacity,transform] duration-200 ease-out",
17
+ ],
18
+ {
19
+ variants: {
20
+ size: {
21
+ sm: "max-w-[400px]",
22
+ md: "max-w-[440px]",
23
+ lg: "max-w-lg",
24
+ xl: "max-w-xl",
25
+ },
26
+ visible: {
27
+ true: "opacity-100 scale-100",
28
+ false: "opacity-0 scale-[0.97]",
29
+ },
30
+ },
31
+ defaultVariants: { size: "md", visible: false },
32
+ },
33
+ );
34
+
35
+ const overlayVariants = cva(
36
+ [
37
+ "absolute inset-0 bg-zinc-900/45",
38
+ "transition-opacity duration-200 ease-out",
39
+ "dark:bg-black/65",
40
+ ],
41
+ {
42
+ variants: {
43
+ visible: {
44
+ true: "opacity-100",
45
+ false: "opacity-0",
46
+ },
47
+ },
48
+ defaultVariants: { visible: false },
49
+ },
50
+ );
51
+
52
+ interface ModalContextValue {
53
+ open: boolean;
54
+ onOpenChange: (open: boolean) => void;
55
+ }
56
+
57
+ const ModalContext = React.createContext<ModalContextValue | null>(null);
58
+
59
+ interface ModalContentContextValue {
60
+ titleId: string;
61
+ descriptionId: string;
62
+ close: () => void;
63
+ }
64
+
65
+ const ModalContentContext = React.createContext<ModalContentContextValue | null>(null);
66
+
67
+ function useModalContext() {
68
+ const context = React.useContext(ModalContext);
69
+ if (!context) {
70
+ throw new Error("Modal components must be used within Modal");
71
+ }
72
+ return context;
73
+ }
74
+
75
+ function useModalContentContext() {
76
+ const context = React.useContext(ModalContentContext);
77
+ if (!context) {
78
+ throw new Error("Modal subcomponents must be used within ModalContent");
79
+ }
80
+ return context;
81
+ }
82
+
83
+ export interface ModalProps {
84
+ open?: boolean;
85
+ onOpenChange?: (open: boolean) => void;
86
+ defaultOpen?: boolean;
87
+ children: React.ReactNode;
88
+ }
89
+
90
+ export function Modal({
91
+ open: controlledOpen,
92
+ onOpenChange,
93
+ defaultOpen = false,
94
+ children,
95
+ }: ModalProps) {
96
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
97
+ const open = controlledOpen ?? uncontrolledOpen;
98
+
99
+ const handleOpenChange = React.useCallback(
100
+ (next: boolean) => {
101
+ if (controlledOpen === undefined) {
102
+ setUncontrolledOpen(next);
103
+ }
104
+ onOpenChange?.(next);
105
+ },
106
+ [controlledOpen, onOpenChange],
107
+ );
108
+
109
+ return (
110
+ <ModalContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
111
+ {children}
112
+ </ModalContext.Provider>
113
+ );
114
+ }
115
+
116
+ export interface ModalTriggerProps extends React.ComponentPropsWithoutRef<"button"> {
117
+ asChild?: boolean;
118
+ }
119
+
120
+ export function ModalTrigger({ asChild, children, onClick, ...props }: ModalTriggerProps) {
121
+ const { onOpenChange } = useModalContext();
122
+
123
+ const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
124
+ onClick?.(event);
125
+ if (!event.defaultPrevented) {
126
+ onOpenChange(true);
127
+ }
128
+ };
129
+
130
+ if (asChild && React.isValidElement(children)) {
131
+ const child = children as React.ReactElement<{ onClick?: React.MouseEventHandler<HTMLElement> }>;
132
+ return React.cloneElement(child, {
133
+ onClick: (event: React.MouseEvent<HTMLElement>) => {
134
+ child.props.onClick?.(event);
135
+ if (!event.defaultPrevented) {
136
+ onOpenChange(true);
137
+ }
138
+ },
139
+ });
140
+ }
141
+
142
+ return (
143
+ <button type="button" onClick={handleOpen} {...props}>
144
+ {children}
145
+ </button>
146
+ );
147
+ }
148
+
149
+ export interface ModalContentProps
150
+ extends React.HTMLAttributes<HTMLDivElement>,
151
+ VariantProps<typeof modalContentVariants> {
152
+ showCloseButton?: boolean;
153
+ onClose?: () => void;
154
+ }
155
+
156
+ export function ModalContent({
157
+ className,
158
+ size,
159
+ showCloseButton = true,
160
+ onClose,
161
+ children,
162
+ ...props
163
+ }: ModalContentProps) {
164
+ const { open, onOpenChange } = useModalContext();
165
+ const [mounted, setMounted] = React.useState(false);
166
+ const [visible, setVisible] = React.useState(false);
167
+ const contentRef = React.useRef<HTMLDivElement>(null);
168
+ const titleId = React.useId();
169
+ const descriptionId = React.useId();
170
+
171
+ React.useEffect(() => setMounted(true), []);
172
+
173
+ React.useEffect(() => {
174
+ if (!open) {
175
+ setVisible(false);
176
+ document.body.style.overflow = "";
177
+ return;
178
+ }
179
+
180
+ document.body.style.overflow = "hidden";
181
+ const frame = requestAnimationFrame(() => setVisible(true));
182
+
183
+ return () => {
184
+ cancelAnimationFrame(frame);
185
+ document.body.style.overflow = "";
186
+ };
187
+ }, [open]);
188
+
189
+ const close = React.useCallback(() => {
190
+ onClose?.();
191
+ onOpenChange(false);
192
+ }, [onClose, onOpenChange]);
193
+
194
+ React.useEffect(() => {
195
+ if (!open) return;
196
+ const handleKeyDown = (event: KeyboardEvent) => {
197
+ if (event.key === "Escape") close();
198
+ };
199
+ window.addEventListener("keydown", handleKeyDown);
200
+ return () => window.removeEventListener("keydown", handleKeyDown);
201
+ }, [open, close]);
202
+
203
+ React.useEffect(() => {
204
+ if (!open || !contentRef.current) return;
205
+ const focusable = contentRef.current.querySelector<HTMLElement>(
206
+ "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])",
207
+ );
208
+ focusable?.focus();
209
+ }, [open, visible]);
210
+
211
+ if (!mounted || !open) return null;
212
+
213
+ const node = (
214
+ <div className="fixed inset-0 z-[100] isolate flex items-center justify-center p-4 sm:p-6">
215
+ <div
216
+ className={overlayVariants({ visible })}
217
+ onClick={close}
218
+ aria-hidden
219
+ />
220
+ <div
221
+ ref={contentRef}
222
+ role="dialog"
223
+ aria-modal="true"
224
+ aria-labelledby={titleId}
225
+ aria-describedby={descriptionId}
226
+ className={cn(modalContentVariants({ size, visible }), className)}
227
+ onClick={(e) => e.stopPropagation()}
228
+ {...props}
229
+ >
230
+ {showCloseButton && (
231
+ <button
232
+ type="button"
233
+ onClick={close}
234
+ className="absolute right-4 top-4 z-10 inline-flex h-8 w-8 items-center justify-center rounded-lg text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
235
+ aria-label="Close"
236
+ >
237
+ <X className="h-4 w-4" strokeWidth={2} />
238
+ </button>
239
+ )}
240
+ <ModalContentContext.Provider value={{ titleId, descriptionId, close }}>
241
+ {children}
242
+ </ModalContentContext.Provider>
243
+ </div>
244
+ </div>
245
+ );
246
+
247
+ return createPortal(node, document.body);
248
+ }
249
+
250
+ export function ModalHeader({
251
+ className,
252
+ ...props
253
+ }: React.HTMLAttributes<HTMLDivElement>) {
254
+ return (
255
+ <div
256
+ className={cn("space-y-1 px-6 pt-6 pr-12", className)}
257
+ {...props}
258
+ />
259
+ );
260
+ }
261
+
262
+ export function ModalTitle({
263
+ className,
264
+ ...props
265
+ }: React.HTMLAttributes<HTMLHeadingElement>) {
266
+ const { titleId } = useModalContentContext();
267
+
268
+ return (
269
+ <h2
270
+ id={titleId}
271
+ className={cn(
272
+ "text-[15px] font-semibold leading-snug tracking-tight text-zinc-900 dark:text-white",
273
+ className,
274
+ )}
275
+ {...props}
276
+ />
277
+ );
278
+ }
279
+
280
+ export function ModalDescription({
281
+ className,
282
+ ...props
283
+ }: React.HTMLAttributes<HTMLParagraphElement>) {
284
+ const { descriptionId } = useModalContentContext();
285
+
286
+ return (
287
+ <p
288
+ id={descriptionId}
289
+ className={cn(
290
+ "text-[13px] leading-relaxed text-zinc-500 dark:text-zinc-400",
291
+ className,
292
+ )}
293
+ {...props}
294
+ />
295
+ );
296
+ }
297
+
298
+ export function ModalBody({
299
+ className,
300
+ ...props
301
+ }: React.HTMLAttributes<HTMLDivElement>) {
302
+ return (
303
+ <div
304
+ className={cn("space-y-4 px-6 pt-5 pb-6", className)}
305
+ {...props}
306
+ />
307
+ );
308
+ }
309
+
310
+ export function ModalFooter({
311
+ className,
312
+ ...props
313
+ }: React.HTMLAttributes<HTMLDivElement>) {
314
+ return (
315
+ <div
316
+ className={cn(
317
+ "flex items-center justify-end gap-2 border-t border-zinc-200/90 px-6 py-4 dark:border-zinc-800",
318
+ className,
319
+ )}
320
+ {...props}
321
+ />
322
+ );
323
+ }
324
+
325
+ export function ModalOption({
326
+ className,
327
+ icon,
328
+ title,
329
+ description,
330
+ ...props
331
+ }: React.HTMLAttributes<HTMLDivElement> & {
332
+ icon?: React.ReactNode;
333
+ title: string;
334
+ description?: string;
335
+ }) {
336
+ return (
337
+ <div
338
+ className={cn(
339
+ "flex gap-3 rounded-xl border border-zinc-200 bg-zinc-50/70 p-3.5",
340
+ "dark:border-zinc-800 dark:bg-zinc-900/60",
341
+ className,
342
+ )}
343
+ {...props}
344
+ >
345
+ {icon && (
346
+ <div
347
+ className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white text-zinc-500 shadow-sm ring-1 ring-zinc-200/80 dark:bg-zinc-950 dark:text-zinc-400 dark:ring-zinc-700"
348
+ >
349
+ {icon}
350
+ </div>
351
+ )}
352
+ <div className="min-w-0">
353
+ <p className="text-sm font-medium text-zinc-900 dark:text-white">{title}</p>
354
+ {description && (
355
+ <p className="mt-0.5 text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
356
+ {description}
357
+ </p>
358
+ )}
359
+ </div>
360
+ </div>
361
+ );
362
+ }
363
+
364
+ export interface ModalCloseProps extends React.ComponentPropsWithoutRef<"button"> {
365
+ asChild?: boolean;
366
+ }
367
+
368
+ export function ModalClose({ asChild, children, onClick, ...props }: ModalCloseProps) {
369
+ const { close } = useModalContentContext();
370
+
371
+ if (asChild && React.isValidElement(children)) {
372
+ return React.cloneElement(
373
+ children as React.ReactElement<{ onClick?: React.MouseEventHandler }>,
374
+ {
375
+ onClick: (event: React.MouseEvent) => {
376
+ (children as React.ReactElement<{ onClick?: React.MouseEventHandler }>).props.onClick?.(
377
+ event,
378
+ );
379
+ close();
380
+ },
381
+ },
382
+ );
383
+ }
384
+
385
+ return (
386
+ <button
387
+ type="button"
388
+ onClick={(e) => {
389
+ onClick?.(e);
390
+ close();
391
+ }}
392
+ {...props}
393
+ >
394
+ {children}
395
+ </button>
396
+ );
397
+ }
398
+
399
+ export { modalContentVariants, overlayVariants };
@@ -0,0 +1,40 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { cn } from "@/lib/cn";
5
+ import { CodeBlock } from "@/components/ui/code-block";
6
+
7
+ const PACKAGE_MANAGERS = [
8
+ { id: "npm", label: "npm", command: "npm install ruler.ui" },
9
+ { id: "yarn", label: "yarn", command: "yarn add ruler.ui" },
10
+ { id: "pnpm", label: "pnpm", command: "pnpm add ruler.ui" },
11
+ { id: "bun", label: "bun", command: "bun add ruler.ui" },
12
+ ] as const;
13
+
14
+ export function PackageManagerTabs() {
15
+ const [active, setActive] = useState<(typeof PACKAGE_MANAGERS)[number]["id"]>("npm");
16
+ const current = PACKAGE_MANAGERS.find((pm) => pm.id === active) ?? PACKAGE_MANAGERS[0];
17
+
18
+ return (
19
+ <div>
20
+ <div className="flex min-w-0 items-center gap-5 overflow-x-auto border-b border-zinc-200 dark:border-zinc-800">
21
+ {PACKAGE_MANAGERS.map((pm) => (
22
+ <button
23
+ key={pm.id}
24
+ type="button"
25
+ onClick={() => setActive(pm.id)}
26
+ className={cn(
27
+ "relative -mb-px border-b-2 px-0.5 py-2.5 text-sm font-medium transition-colors",
28
+ active === pm.id
29
+ ? "border-zinc-900 text-zinc-900 dark:border-white dark:text-zinc-50"
30
+ : "border-transparent text-zinc-500 hover:text-zinc-700 dark:text-zinc-500 dark:hover:text-zinc-300",
31
+ )}
32
+ >
33
+ {pm.label}
34
+ </button>
35
+ ))}
36
+ </div>
37
+ <CodeBlock code={current.command} className="mt-4" />
38
+ </div>
39
+ );
40
+ }
@@ -0,0 +1,200 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
5
+ import { cn } from "@/lib/cn";
6
+ import { Button } from "./button";
7
+
8
+ function getPageList(current: number, total: number, siblingCount: number): (number | "...")[] {
9
+ const totalSlots = siblingCount * 2 + 5;
10
+ if (total <= totalSlots) return Array.from({ length: total }, (_, i) => i + 1);
11
+
12
+ const left = Math.max(current - siblingCount, 1);
13
+ const right = Math.min(current + siblingCount, total);
14
+
15
+ const pages: (number | "...")[] = [1];
16
+ if (left > 2) pages.push("...");
17
+ for (let p = Math.max(left, 2); p <= Math.min(right, total - 1); p++) pages.push(p);
18
+ if (right < total - 1) pages.push("...");
19
+ if (total > 1) pages.push(total);
20
+ return pages;
21
+ }
22
+
23
+ export interface PaginationProps {
24
+ page: number;
25
+ totalPages: number;
26
+ onPageChange: (page: number) => void;
27
+ siblingCount?: number;
28
+ variant?: "default" | "simple";
29
+ size?: "small" | "half" | "full";
30
+ className?: string;
31
+ }
32
+
33
+ const navTransition =
34
+ "transition-[color,background-color,border-color,box-shadow,transform,opacity,width] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)]";
35
+ const popIn = "animate-[pagination-pop_220ms_cubic-bezier(0.16,1,0.3,1)]";
36
+ const focusRing = "focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900";
37
+
38
+ const WIDTH_CLASSES = {
39
+ small: "w-max max-w-full",
40
+ half: "w-full min-w-0 sm:w-1/2 sm:min-w-max sm:max-w-full",
41
+ full: "w-full min-w-0",
42
+ };
43
+
44
+ const BUTTON_SIZE = 32;
45
+ const GAP = 4;
46
+
47
+ export function Pagination({
48
+ page,
49
+ totalPages,
50
+ onPageChange,
51
+ siblingCount = 1,
52
+ variant = "default",
53
+ size = "small",
54
+ className,
55
+ }: PaginationProps) {
56
+ const clampedTotal = Math.max(1, totalPages);
57
+ const current = Math.min(Math.max(page, 1), clampedTotal);
58
+ const goTo = (p: number) => onPageChange(Math.min(Math.max(p, 1), clampedTotal));
59
+
60
+ const pages = React.useMemo(
61
+ () => getPageList(current, clampedTotal, siblingCount),
62
+ [current, clampedTotal, siblingCount],
63
+ );
64
+
65
+ const maxSlots = Math.min(clampedTotal, siblingCount * 2 + 5);
66
+ const trackMinWidth = maxSlots * BUTTON_SIZE + (maxSlots - 1) * GAP;
67
+
68
+ // FLIP: glide numbers/ellipses that persist across a page change to their new
69
+ // position instead of snapping — scoped entirely to this component's own DOM,
70
+ // so it never touches other Pagination instances on the page.
71
+ const trackRef = React.useRef<HTMLDivElement>(null);
72
+ const prevRectsRef = React.useRef(new Map<string, DOMRect>());
73
+
74
+ React.useLayoutEffect(() => {
75
+ const track = trackRef.current;
76
+ if (!track) return;
77
+ const prevRects = prevRectsRef.current;
78
+ const nextRects = new Map<string, DOMRect>();
79
+
80
+ track.querySelectorAll<HTMLElement>("[data-flip-key]").forEach((el) => {
81
+ const key = el.dataset.flipKey!;
82
+ const rect = el.getBoundingClientRect();
83
+ nextRects.set(key, rect);
84
+
85
+ const prev = prevRects.get(key);
86
+ if (!prev) return;
87
+ const dx = prev.left - rect.left;
88
+ if (Math.abs(dx) < 0.5) return;
89
+
90
+ el.style.transition = "none";
91
+ el.style.transform = `translateX(${dx}px)`;
92
+ el.getBoundingClientRect();
93
+ requestAnimationFrame(() => {
94
+ el.style.transition = "transform 220ms cubic-bezier(0.16,1,0.3,1)";
95
+ el.style.transform = "";
96
+ });
97
+ });
98
+
99
+ prevRectsRef.current = nextRects;
100
+ }, [pages]);
101
+
102
+ if (variant === "simple") {
103
+ return (
104
+ <nav
105
+ aria-label="Pagination"
106
+ className={cn(
107
+ "flex items-center justify-between gap-2 overflow-x-auto rounded-full border border-zinc-200/70 bg-white p-1.5 shadow-[0_2px_8px_rgba(0,0,0,0.06)] transition-[width] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] dark:border-zinc-800/80 dark:bg-zinc-900 dark:shadow-[0_2px_8px_rgba(0,0,0,0.25)]",
108
+ WIDTH_CLASSES[size],
109
+ className,
110
+ )}
111
+ >
112
+ <Button variant="ghost" size="sm" className={cn("rounded-full", navTransition, focusRing)} disabled={current <= 1} onClick={() => goTo(current - 1)}>
113
+ <ChevronLeft size={15} />
114
+ Previous
115
+ </Button>
116
+ <p className="min-w-[6.5rem] text-center text-xs tabular-nums text-zinc-500 dark:text-zinc-400">
117
+ Page {current} of {clampedTotal}
118
+ </p>
119
+ <Button variant="ghost" size="sm" className={cn("rounded-full", navTransition, focusRing)} disabled={current >= clampedTotal} onClick={() => goTo(current + 1)}>
120
+ Next
121
+ <ChevronRight size={15} />
122
+ </Button>
123
+ </nav>
124
+ );
125
+ }
126
+
127
+ return (
128
+ <nav
129
+ aria-label="Pagination"
130
+ className={cn(
131
+ "flex items-center gap-1 overflow-x-auto rounded-full border border-zinc-200/70 bg-white p-1 shadow-[0_2px_8px_rgba(0,0,0,0.06)] transition-[width] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] dark:border-zinc-800/80 dark:bg-zinc-900 dark:shadow-[0_2px_8px_rgba(0,0,0,0.25)]",
132
+ WIDTH_CLASSES[size],
133
+ className,
134
+ )}
135
+ >
136
+ <Button
137
+ variant="ghost"
138
+ size="sm"
139
+ className={cn("h-8 w-8 shrink-0 rounded-full p-0", navTransition, focusRing)}
140
+ disabled={current <= 1}
141
+ onClick={() => goTo(current - 1)}
142
+ aria-label="Previous page"
143
+ >
144
+ <ChevronLeft size={15} />
145
+ </Button>
146
+
147
+ <div
148
+ ref={trackRef}
149
+ className="flex min-w-0 flex-1 items-center justify-center gap-1"
150
+ style={size === "small" ? { minWidth: trackMinWidth } : undefined}
151
+ >
152
+ {pages.map((p, i) =>
153
+ p === "..." ? (
154
+ <span
155
+ key={`ellipsis-${i}`}
156
+ data-flip-key={`e-${i}`}
157
+ className={cn("flex h-8 w-8 shrink-0 items-center justify-center text-zinc-400", popIn)}
158
+ aria-hidden="true"
159
+ >
160
+ <MoreHorizontal size={15} />
161
+ </span>
162
+ ) : (
163
+ <button
164
+ key={p}
165
+ type="button"
166
+ data-flip-key={`p-${p}`}
167
+ onClick={() => goTo(p)}
168
+ aria-current={current === p ? "page" : undefined}
169
+ aria-label={`Page ${p}`}
170
+ className={cn(
171
+ "h-8 w-8 shrink-0 cursor-pointer rounded-full text-sm font-medium tabular-nums outline-none",
172
+ "focus-visible:ring-2 focus-visible:ring-zinc-900/15 focus-visible:ring-offset-2 dark:focus-visible:ring-white/20",
173
+ "active:scale-90 active:transition-transform active:duration-100",
174
+ navTransition,
175
+ popIn,
176
+ focusRing,
177
+ current === p
178
+ ? "bg-zinc-900 text-white shadow-[0_2px_6px_rgba(0,0,0,0.3)] dark:bg-white dark:text-zinc-900 dark:shadow-[0_2px_6px_rgba(0,0,0,0.5)]"
179
+ : "text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100",
180
+ )}
181
+ >
182
+ {p}
183
+ </button>
184
+ ),
185
+ )}
186
+ </div>
187
+
188
+ <Button
189
+ variant="ghost"
190
+ size="sm"
191
+ className={cn("h-8 w-8 shrink-0 rounded-full p-0", navTransition, focusRing)}
192
+ disabled={current >= clampedTotal}
193
+ onClick={() => goTo(current + 1)}
194
+ aria-label="Next page"
195
+ >
196
+ <ChevronRight size={15} />
197
+ </Button>
198
+ </nav>
199
+ );
200
+ }