@zmzai/theme 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 (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +76 -0
  3. package/package.json +91 -0
  4. package/src/brand/index.ts +3 -0
  5. package/src/brand/logo/Logo.tsx +46 -0
  6. package/src/brand/logo/favicon.png +0 -0
  7. package/src/brand/logo/index.ts +2 -0
  8. package/src/brand/logo/zmzai-logo.png +0 -0
  9. package/src/brand/wordmark/Wordmark.tsx +48 -0
  10. package/src/brand/wordmark/index.ts +2 -0
  11. package/src/components/aceternity-sidebar/AceternitySidebar.tsx +133 -0
  12. package/src/components/aceternity-sidebar/index.ts +2 -0
  13. package/src/components/animated-tabs/AnimatedTabs.tsx +60 -0
  14. package/src/components/animated-tabs/index.ts +2 -0
  15. package/src/components/animated-tooltip/AnimatedTooltip.tsx +93 -0
  16. package/src/components/animated-tooltip/index.ts +2 -0
  17. package/src/components/avatar/Avatar.tsx +59 -0
  18. package/src/components/avatar/index.ts +2 -0
  19. package/src/components/badge/Badge.tsx +45 -0
  20. package/src/components/badge/index.ts +2 -0
  21. package/src/components/bento-grid/BentoGrid.tsx +85 -0
  22. package/src/components/bento-grid/index.ts +2 -0
  23. package/src/components/button/Button.tsx +66 -0
  24. package/src/components/button/button.styles.ts +41 -0
  25. package/src/components/button/index.ts +4 -0
  26. package/src/components/card/Card.tsx +87 -0
  27. package/src/components/card/card.styles.ts +35 -0
  28. package/src/components/card/index.ts +4 -0
  29. package/src/components/card-3d/Card3D.tsx +84 -0
  30. package/src/components/card-3d/index.ts +2 -0
  31. package/src/components/card-hover-effect/CardHoverEffect.tsx +83 -0
  32. package/src/components/card-hover-effect/index.ts +2 -0
  33. package/src/components/card-spotlight/CardSpotlight.tsx +63 -0
  34. package/src/components/card-spotlight/index.ts +2 -0
  35. package/src/components/carousel/Carousel.tsx +79 -0
  36. package/src/components/carousel/index.ts +2 -0
  37. package/src/components/code-block/CodeBlock.tsx +153 -0
  38. package/src/components/code-block/index.ts +2 -0
  39. package/src/components/comet-card/CometCard.tsx +106 -0
  40. package/src/components/comet-card/index.ts +2 -0
  41. package/src/components/compare/Compare.tsx +128 -0
  42. package/src/components/compare/index.ts +2 -0
  43. package/src/components/dialog/Dialog.tsx +79 -0
  44. package/src/components/dialog/index.ts +10 -0
  45. package/src/components/dropdown-menu/DropdownMenu.tsx +76 -0
  46. package/src/components/dropdown-menu/index.ts +8 -0
  47. package/src/components/empty-state/EmptyState.tsx +78 -0
  48. package/src/components/empty-state/index.ts +2 -0
  49. package/src/components/encrypt-text/EncryptText.tsx +102 -0
  50. package/src/components/encrypt-text/index.ts +2 -0
  51. package/src/components/evervault-card/EvervaultCard.tsx +114 -0
  52. package/src/components/evervault-card/index.ts +2 -0
  53. package/src/components/expandable-card/ExpandableCard.tsx +91 -0
  54. package/src/components/expandable-card/index.ts +2 -0
  55. package/src/components/file-upload/FileUpload.tsx +171 -0
  56. package/src/components/file-upload/index.ts +2 -0
  57. package/src/components/flip-words/FlipWords.tsx +70 -0
  58. package/src/components/flip-words/index.ts +2 -0
  59. package/src/components/floating-navbar/FloatingNavbar.tsx +82 -0
  60. package/src/components/floating-navbar/index.ts +2 -0
  61. package/src/components/focus-cards/FocusCards.tsx +93 -0
  62. package/src/components/focus-cards/index.ts +2 -0
  63. package/src/components/glare-card/GlareCard.tsx +66 -0
  64. package/src/components/glare-card/index.ts +2 -0
  65. package/src/components/glowing-effect/GlowingEffect.tsx +68 -0
  66. package/src/components/glowing-effect/index.ts +2 -0
  67. package/src/components/hover-border-gradient/HoverBorderGradient.tsx +80 -0
  68. package/src/components/hover-border-gradient/index.ts +2 -0
  69. package/src/components/index.ts +71 -0
  70. package/src/components/infinite-moving-cards/InfiniteMovingCards.tsx +84 -0
  71. package/src/components/infinite-moving-cards/index.ts +2 -0
  72. package/src/components/input/Input.tsx +35 -0
  73. package/src/components/input/index.ts +4 -0
  74. package/src/components/input/input.styles.ts +34 -0
  75. package/src/components/keyboard/Keyboard.tsx +38 -0
  76. package/src/components/keyboard/index.ts +2 -0
  77. package/src/components/layout-grid/LayoutGrid.tsx +121 -0
  78. package/src/components/layout-grid/index.ts +2 -0
  79. package/src/components/link-preview/LinkPreview.tsx +121 -0
  80. package/src/components/link-preview/index.ts +2 -0
  81. package/src/components/loader/Loader.tsx +130 -0
  82. package/src/components/loader/index.ts +2 -0
  83. package/src/components/magnetic-button/MagneticButton.tsx +79 -0
  84. package/src/components/magnetic-button/index.ts +2 -0
  85. package/src/components/moving-border/MovingBorder.tsx +72 -0
  86. package/src/components/moving-border/index.ts +2 -0
  87. package/src/components/multi-step-loader/MultiStepLoader.tsx +95 -0
  88. package/src/components/multi-step-loader/index.ts +2 -0
  89. package/src/components/notch/Notch.tsx +47 -0
  90. package/src/components/notch/index.ts +2 -0
  91. package/src/components/select/Select.tsx +100 -0
  92. package/src/components/select/index.ts +10 -0
  93. package/src/components/skeleton/Skeleton.tsx +34 -0
  94. package/src/components/skeleton/index.ts +2 -0
  95. package/src/components/stateful-button/StatefulButton.tsx +115 -0
  96. package/src/components/stateful-button/index.ts +2 -0
  97. package/src/components/sticky-banner/StickyBanner.tsx +74 -0
  98. package/src/components/sticky-banner/index.ts +2 -0
  99. package/src/components/sticky-scroll-reveal/StickyScrollReveal.tsx +141 -0
  100. package/src/components/sticky-scroll-reveal/index.ts +2 -0
  101. package/src/components/terminal/Terminal.tsx +55 -0
  102. package/src/components/terminal/index.ts +2 -0
  103. package/src/components/text-generate/TextGenerate.tsx +59 -0
  104. package/src/components/text-generate/index.ts +2 -0
  105. package/src/components/text-hover-effect/TextHoverEffect.tsx +90 -0
  106. package/src/components/text-hover-effect/index.ts +2 -0
  107. package/src/components/textarea/Textarea.tsx +39 -0
  108. package/src/components/textarea/index.ts +2 -0
  109. package/src/components/timeline/Timeline.tsx +106 -0
  110. package/src/components/timeline/index.ts +3 -0
  111. package/src/components/tooltip/Tooltip.tsx +51 -0
  112. package/src/components/tooltip/index.ts +2 -0
  113. package/src/components/tooltip-card/TooltipCard.tsx +130 -0
  114. package/src/components/tooltip-card/index.ts +2 -0
  115. package/src/components/tracing-beam/TracingBeam.tsx +110 -0
  116. package/src/components/tracing-beam/index.ts +2 -0
  117. package/src/components/typewriter/Typewriter.tsx +69 -0
  118. package/src/components/typewriter/index.ts +2 -0
  119. package/src/components/vanish-input/VanishInput.tsx +170 -0
  120. package/src/components/vanish-input/index.ts +2 -0
  121. package/src/components/wobble-card/WobbleCard.tsx +77 -0
  122. package/src/components/wobble-card/index.ts +2 -0
  123. package/src/index.ts +21 -0
  124. package/src/tokens/fonts.css +42 -0
  125. package/src/tokens/theme.css +120 -0
  126. package/src/utils/cn.ts +13 -0
  127. package/src/utils/index.ts +2 -0
  128. package/src/utils/motion.ts +47 -0
  129. package/src/vite-env.d.ts +6 -0
@@ -0,0 +1,71 @@
1
+ // @zmzai/theme — Components barrel (53 components)
2
+
3
+ // === Basic ===
4
+ export * from "./button";
5
+ export * from "./input";
6
+ export * from "./textarea";
7
+ export * from "./card";
8
+ export * from "./badge";
9
+ export * from "./avatar";
10
+
11
+ // === Radix-based ===
12
+ export * from "./tooltip";
13
+ export * from "./dialog";
14
+ export * from "./dropdown-menu";
15
+ export * from "./select";
16
+
17
+ // === Interactive Enhancement ===
18
+ export * from "./magnetic-button";
19
+ export * from "./glowing-effect";
20
+ export * from "./text-hover-effect";
21
+ export * from "./hover-border-gradient";
22
+ export * from "./card-spotlight";
23
+ export * from "./moving-border";
24
+ export * from "./stateful-button";
25
+
26
+ // === Layout & Structure ===
27
+ export * from "./floating-navbar";
28
+ export * from "./aceternity-sidebar";
29
+ export * from "./bento-grid";
30
+ export * from "./expandable-card";
31
+ export * from "./focus-cards";
32
+ export * from "./layout-grid";
33
+ export * from "./animated-tabs";
34
+
35
+ // === Content Display ===
36
+ export * from "./tracing-beam";
37
+ export * from "./sticky-scroll-reveal";
38
+ export * from "./file-upload";
39
+ export * from "./vanish-input";
40
+ export * from "./link-preview";
41
+ export * from "./animated-tooltip";
42
+ export * from "./tooltip-card";
43
+ export * from "./code-block";
44
+ export * from "./multi-step-loader";
45
+ export * from "./timeline";
46
+
47
+ // === Text Effects ===
48
+ export * from "./text-generate";
49
+ export * from "./encrypt-text";
50
+ export * from "./typewriter";
51
+ export * from "./flip-words";
52
+
53
+ // === Card Variants ===
54
+ export * from "./card-hover-effect";
55
+ export * from "./wobble-card";
56
+ export * from "./glare-card";
57
+ export * from "./comet-card";
58
+ export * from "./evervault-card";
59
+ export * from "./card-3d";
60
+
61
+ // === Utilities ===
62
+ export * from "./terminal";
63
+ export * from "./keyboard";
64
+ export * from "./notch";
65
+ export * from "./sticky-banner";
66
+ export * from "./compare";
67
+ export * from "./infinite-moving-cards";
68
+ export * from "./loader";
69
+ export * from "./skeleton";
70
+ export * from "./empty-state";
71
+ export * from "./carousel";
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ import { type CSSProperties, type HTMLAttributes, type ReactNode } from "react";
4
+ import { cn } from "../../utils/cn";
5
+
6
+ export interface InfiniteMovingCardsProps extends HTMLAttributes<HTMLDivElement> {
7
+ /** Cards to render in the marquee */
8
+ items: ReactNode[];
9
+ /** Scroll speed */
10
+ speed?: "slow" | "normal" | "fast";
11
+ /** Pause the animation on hover */
12
+ pauseOnHover?: boolean;
13
+ /** Scroll direction */
14
+ direction?: "left" | "right";
15
+ }
16
+
17
+ const speedMap: Record<NonNullable<InfiniteMovingCardsProps["speed"]>, string> = {
18
+ slow: "80s",
19
+ normal: "40s",
20
+ fast: "20s",
21
+ };
22
+
23
+ /**
24
+ * InfiniteMovingCards — a horizontal marquee that loops forever.
25
+ *
26
+ * Renders `items` twice back-to-back inside a CSS keyframe animation so the
27
+ * loop is seamless. Pure CSS (no JS frame loop). The keyframes are injected
28
+ * once via a scoped style tag to keep the component self-contained.
29
+ *
30
+ * @example
31
+ * <InfiniteMovingCards items={logos} speed="slow" direction="left" />
32
+ */
33
+ export function InfiniteMovingCards({
34
+ items,
35
+ speed = "normal",
36
+ pauseOnHover = true,
37
+ direction = "left",
38
+ className,
39
+ ...props
40
+ }: InfiniteMovingCardsProps) {
41
+ const duration = speedMap[speed];
42
+
43
+ const trackStyle: CSSProperties = {
44
+ animationDuration: duration,
45
+ animationDirection: direction === "right" ? "reverse" : "normal",
46
+ animationPlayState: "running",
47
+ };
48
+
49
+ return (
50
+ <>
51
+ <style>{`
52
+ @keyframes zmzai-marquee {
53
+ from { transform: translateX(0); }
54
+ to { transform: translateX(-50%); }
55
+ }
56
+ .zmzai-marquee-track {
57
+ animation: zmzai-marquee var(--marquee-duration, 40s) linear infinite;
58
+ }
59
+ `}</style>
60
+
61
+ <div
62
+ className={cn(
63
+ "group relative flex overflow-hidden",
64
+ className
65
+ )}
66
+ {...props}
67
+ >
68
+ <div
69
+ className={cn(
70
+ "zmzai-marquee-track flex w-max shrink-0 flex-nowrap items-stretch gap-4",
71
+ pauseOnHover && "group-hover:[animation-play-state:paused]"
72
+ )}
73
+ style={{ ...trackStyle, ["--marquee-duration" as string]: duration }}
74
+ >
75
+ {[...items, ...items].map((item, i) => (
76
+ <div key={i} className="shrink-0">
77
+ {item}
78
+ </div>
79
+ ))}
80
+ </div>
81
+ </div>
82
+ </>
83
+ );
84
+ }
@@ -0,0 +1,2 @@
1
+ export { InfiniteMovingCards } from "./InfiniteMovingCards";
2
+ export type { InfiniteMovingCardsProps } from "./InfiniteMovingCards";
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ import { forwardRef, type InputHTMLAttributes } from "react";
4
+ import { cn } from "../../utils/cn";
5
+ import { inputVariants, type InputVariant, type InputSize } from "./input.styles";
6
+
7
+ export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
8
+ variant?: InputVariant;
9
+ size?: InputSize;
10
+ }
11
+
12
+ /**
13
+ * Input — text input field.
14
+ *
15
+ * - `default`: light border, focus turns border to ink
16
+ * - `brutal`: 2px ink border + hard offset shadow, focus shifts (neobrutalism)
17
+ *
18
+ * @example
19
+ * <Input placeholder="搜索…" />
20
+ * <Input variant="brutal" placeholder="描述任务…" />
21
+ */
22
+ export const Input = forwardRef<HTMLInputElement, InputProps>(
23
+ ({ className, variant, size, type = "text", ...props }, ref) => {
24
+ return (
25
+ <input
26
+ ref={ref}
27
+ type={type}
28
+ className={cn(inputVariants({ variant, size }), className)}
29
+ {...props}
30
+ />
31
+ );
32
+ }
33
+ );
34
+
35
+ Input.displayName = "Input";
@@ -0,0 +1,4 @@
1
+ export { Input } from "./Input";
2
+ export type { InputProps } from "./Input";
3
+ export { inputVariants } from "./input.styles";
4
+ export type { InputVariant, InputSize } from "./input.styles";
@@ -0,0 +1,34 @@
1
+ import { cva } from "class-variance-authority";
2
+
3
+ /**
4
+ * Input variant styles.
5
+ *
6
+ * Design:
7
+ * - default: light border, focus turns border to ink
8
+ * - brutal: 2px ink border + hard offset shadow (neobrutalism), focus shifts
9
+ */
10
+ export const inputVariants = cva(
11
+ "w-full bg-bg font-sans text-ink placeholder:text-ink-3 outline-none transition-all",
12
+ {
13
+ variants: {
14
+ variant: {
15
+ default:
16
+ "border border-line rounded-lg px-4 py-2.5 text-sm focus:border-ink",
17
+ brutal:
18
+ "border-2 border-ink rounded-xl px-4 py-3 text-sm shadow-[4px_4px_0_var(--color-ink)] focus:shadow-[6px_6px_0_var(--color-accent)] focus:border-accent focus:-translate-x-px focus:-translate-y-px",
19
+ },
20
+ size: {
21
+ sm: "text-xs py-2 px-3",
22
+ md: "text-sm py-2.5 px-4",
23
+ lg: "text-base py-3 px-5",
24
+ },
25
+ },
26
+ defaultVariants: {
27
+ variant: "default",
28
+ size: "md",
29
+ },
30
+ }
31
+ );
32
+
33
+ export type InputVariant = "default" | "brutal";
34
+ export type InputSize = "sm" | "md" | "lg";
@@ -0,0 +1,38 @@
1
+ "use client";
2
+
3
+ import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
4
+ import { cn } from "../../utils/cn";
5
+
6
+ export interface KeyboardProps extends HTMLAttributes<HTMLElement> {
7
+ /** Key label(s), e.g. ⌘K */
8
+ children?: ReactNode;
9
+ }
10
+
11
+ /**
12
+ * Keyboard — a single keycap pill (e.g. ⌘K).
13
+ *
14
+ * NOT a full keyboard layout — renders one key with a border, mono font,
15
+ * and a subtle shadow for a physical-key look.
16
+ *
17
+ * @example
18
+ * <Keyboard>⌘K</Keyboard>
19
+ * <Keyboard className="px-3">Shift</Keyboard>
20
+ */
21
+ export const Keyboard = forwardRef<HTMLElement, KeyboardProps>(
22
+ ({ children, className, ...props }, ref) => {
23
+ return (
24
+ <kbd
25
+ ref={ref as React.Ref<HTMLElement>}
26
+ className={cn(
27
+ "inline-flex select-none items-center justify-center whitespace-nowrap rounded-md border border-line bg-surface px-1.5 py-0.5 font-mono text-xs font-medium text-ink-2 shadow-sm",
28
+ className
29
+ )}
30
+ {...props}
31
+ >
32
+ {children}
33
+ </kbd>
34
+ );
35
+ }
36
+ );
37
+
38
+ Keyboard.displayName = "Keyboard";
@@ -0,0 +1,2 @@
1
+ export { Keyboard } from "./Keyboard";
2
+ export type { KeyboardProps } from "./Keyboard";
@@ -0,0 +1,121 @@
1
+ "use client";
2
+
3
+ import { useState, type ReactNode } from "react";
4
+ import { motion, AnimatePresence } from "framer-motion";
5
+ import { cn } from "../../utils/cn";
6
+
7
+ export interface LayoutGridItem {
8
+ id: string;
9
+ title: string;
10
+ content: ReactNode;
11
+ icon?: ReactNode;
12
+ }
13
+
14
+ export interface LayoutGridProps {
15
+ items: LayoutGridItem[];
16
+ className?: string;
17
+ }
18
+
19
+ /**
20
+ * LayoutGrid — grid where clicking an item animates it to fullscreen.
21
+ *
22
+ * Uses framer-motion `layoutId` so the clicked card smoothly morphs into a
23
+ * fullscreen overlay. Clicking the overlay (or close button) collapses it back.
24
+ *
25
+ * @example
26
+ * <LayoutGrid items={[
27
+ * { id: "1", title: "项目 A", content: <p>详情…</p> },
28
+ * { id: "2", title: "项目 B", content: <p>详情…</p> },
29
+ * ]} />
30
+ */
31
+ export function LayoutGrid({ items, className }: LayoutGridProps) {
32
+ const [activeId, setActiveId] = useState<string | null>(null);
33
+ const activeItem = items.find((item) => item.id === activeId) ?? null;
34
+
35
+ return (
36
+ <>
37
+ <div
38
+ className={cn(
39
+ "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3",
40
+ className
41
+ )}
42
+ >
43
+ {items.map((item) => (
44
+ <motion.button
45
+ type="button"
46
+ key={item.id}
47
+ layoutId={`layout-card-${item.id}`}
48
+ onClick={() => setActiveId(item.id)}
49
+ whileHover={{ y: -4 }}
50
+ transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
51
+ className={cn(
52
+ "flex min-h-[160px] flex-col rounded-xl border border-line bg-bg p-5 text-left shadow-sm transition-colors hover:border-line-strong"
53
+ )}
54
+ >
55
+ {item.icon && (
56
+ <div className="mb-3 flex h-8 w-8 items-center justify-center text-ink">
57
+ {item.icon}
58
+ </div>
59
+ )}
60
+ <h3 className="mt-auto text-base font-semibold text-ink">
61
+ {item.title}
62
+ </h3>
63
+ </motion.button>
64
+ ))}
65
+ </div>
66
+
67
+ {/* Fullscreen overlay */}
68
+ <AnimatePresence>
69
+ {activeItem && (
70
+ <motion.div
71
+ className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-8"
72
+ initial={{ backgroundColor: "rgba(0,0,0,0)" }}
73
+ animate={{ backgroundColor: "rgba(0,0,0,0.5)" }}
74
+ exit={{ backgroundColor: "rgba(0,0,0,0)" }}
75
+ transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
76
+ onClick={() => setActiveId(null)}
77
+ >
78
+ <motion.div
79
+ layoutId={`layout-card-${activeItem.id}`}
80
+ onClick={(e) => e.stopPropagation()}
81
+ className="relative max-h-[85vh] w-full max-w-2xl overflow-y-auto rounded-xl border border-line bg-bg p-6 shadow-2xl"
82
+ >
83
+ {/* Close button */}
84
+ <button
85
+ type="button"
86
+ onClick={() => setActiveId(null)}
87
+ aria-label="关闭"
88
+ className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full text-ink-2 transition-colors hover:bg-surface-2 hover:text-ink"
89
+ >
90
+ <svg
91
+ className="h-4 w-4"
92
+ viewBox="0 0 24 24"
93
+ fill="none"
94
+ stroke="currentColor"
95
+ strokeWidth="2"
96
+ strokeLinecap="round"
97
+ strokeLinejoin="round"
98
+ >
99
+ <line x1="18" y1="6" x2="6" y2="18" />
100
+ <line x1="6" y1="6" x2="18" y2="18" />
101
+ </svg>
102
+ </button>
103
+
104
+ {activeItem.icon && (
105
+ <div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-surface-2 text-ink">
106
+ {activeItem.icon}
107
+ </div>
108
+ )}
109
+ <h2 className="mb-4 pr-10 text-xl font-bold text-ink">
110
+ {activeItem.title}
111
+ </h2>
112
+ <div className="text-sm leading-relaxed text-ink-2">
113
+ {activeItem.content}
114
+ </div>
115
+ </motion.div>
116
+ </motion.div>
117
+ )}
118
+ </AnimatePresence>
119
+ </>
120
+ );
121
+ }
@@ -0,0 +1,2 @@
1
+ export { LayoutGrid } from "./LayoutGrid";
2
+ export type { LayoutGridProps, LayoutGridItem } from "./LayoutGrid";
@@ -0,0 +1,121 @@
1
+ "use client";
2
+
3
+ import {
4
+ useRef,
5
+ useState,
6
+ type ReactNode,
7
+ type MouseEvent,
8
+ } from "react";
9
+ import {
10
+ motion,
11
+ useMotionValue,
12
+ useSpring,
13
+ AnimatePresence,
14
+ } from "framer-motion";
15
+ import { cn } from "../../utils/cn";
16
+
17
+ export interface LinkPreviewProps {
18
+ /** The trigger element — typically an <a> or text */
19
+ children: ReactNode;
20
+ url: string;
21
+ title: string;
22
+ description: string;
23
+ /** Optional preview image URL */
24
+ image?: string;
25
+ className?: string;
26
+ }
27
+
28
+ /**
29
+ * LinkPreview — hover a link to reveal a floating preview card.
30
+ *
31
+ * On hover, a card (image + title + description) fades/scales in and
32
+ * follows the cursor horizontally. Based on Aceternity's Link Preview,
33
+ * adapted to the zmzai monochrome palette.
34
+ *
35
+ * @example
36
+ * <LinkPreview
37
+ * url="https://example.com"
38
+ * title="示例标题"
39
+ * description="一段描述文字"
40
+ * image="/og.png"
41
+ * >
42
+ * <a href="https://example.com">悬停查看</a>
43
+ * </LinkPreview>
44
+ */
45
+ export function LinkPreview({
46
+ children,
47
+ url,
48
+ title,
49
+ description,
50
+ image,
51
+ className,
52
+ }: LinkPreviewProps) {
53
+ const ref = useRef<HTMLDivElement>(null);
54
+ const [hovered, setHovered] = useState(false);
55
+
56
+ // raw mouse position (relative to viewport)
57
+ const mouseX = useMotionValue(0);
58
+ const mouseY = useMotionValue(0);
59
+
60
+ // spring-smoothed for the card
61
+ const springX = useSpring(mouseX, { stiffness: 200, damping: 25 });
62
+ const springY = useSpring(mouseY, { stiffness: 200, damping: 25 });
63
+
64
+ const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
65
+ mouseX.set(e.clientX);
66
+ mouseY.set(e.clientY);
67
+ };
68
+
69
+ return (
70
+ <div
71
+ ref={ref}
72
+ onMouseEnter={() => setHovered(true)}
73
+ onMouseLeave={() => setHovered(false)}
74
+ onMouseMove={handleMouseMove}
75
+ className={cn("relative inline-block", className)}
76
+ >
77
+ {children}
78
+
79
+ {/* Floating preview card — portal-free, fixed to viewport */}
80
+ <AnimatePresence>
81
+ {hovered && (
82
+ <motion.div
83
+ initial={{ opacity: 0, scale: 0.92, y: 8 }}
84
+ animate={{ opacity: 1, scale: 1, y: 0 }}
85
+ exit={{ opacity: 0, scale: 0.92, y: 8 }}
86
+ transition={{ type: "spring", stiffness: 260, damping: 24 }}
87
+ style={{
88
+ position: "fixed",
89
+ top: 0,
90
+ left: 0,
91
+ // offset so the card sits above the cursor; translate to center
92
+ x: springX,
93
+ y: springY,
94
+ // shift card up & left so it doesn't sit under the cursor
95
+ translateX: "-50%",
96
+ translateY: "-130%",
97
+ }}
98
+ className="pointer-events-none z-50 w-72 origin-bottom overflow-hidden rounded-xl border border-line bg-bg shadow-lg"
99
+ >
100
+ {image && (
101
+ <div className="aspect-video w-full overflow-hidden bg-surface">
102
+ <img
103
+ src={image}
104
+ alt={title}
105
+ className="h-full w-full object-cover"
106
+ />
107
+ </div>
108
+ )}
109
+ <div className="space-y-1.5 p-4">
110
+ <h4 className="text-sm font-semibold text-ink">{title}</h4>
111
+ <p className="line-clamp-2 text-xs text-ink-2">
112
+ {description}
113
+ </p>
114
+ <p className="truncate text-[11px] text-ink-3">{url}</p>
115
+ </div>
116
+ </motion.div>
117
+ )}
118
+ </AnimatePresence>
119
+ </div>
120
+ );
121
+ }
@@ -0,0 +1,2 @@
1
+ export { LinkPreview } from "./LinkPreview";
2
+ export type { LinkPreviewProps } from "./LinkPreview";
@@ -0,0 +1,130 @@
1
+ "use client";
2
+
3
+ import { type HTMLAttributes } from "react";
4
+ import { motion } from "framer-motion";
5
+ import { cn } from "../../utils/cn";
6
+
7
+ export type LoaderVariant = "spinner" | "dots" | "bars" | "ring" | "pulse";
8
+
9
+ export interface LoaderProps extends HTMLAttributes<HTMLDivElement> {
10
+ /** Spinner style */
11
+ variant?: LoaderVariant;
12
+ /** Size in pixels (default 24) */
13
+ size?: number;
14
+ }
15
+
16
+ const easeOut = [0.16, 1, 0.3, 1] as const;
17
+
18
+ /**
19
+ * Loader — minimal loading indicators.
20
+ *
21
+ * Variants:
22
+ * - `spinner`: rotating arc
23
+ * - `dots`: three bouncing dots
24
+ * - `bars`: four equalizing bars
25
+ * - `ring`: pulsing ring
26
+ * - `pulse`: single pulsing dot
27
+ *
28
+ * Default size 24px, monochrome `text-ink`.
29
+ *
30
+ * @example
31
+ * <Loader variant="dots" size={28} />
32
+ */
33
+ export function Loader({
34
+ variant = "spinner",
35
+ size = 24,
36
+ className,
37
+ style,
38
+ ...props
39
+ }: LoaderProps) {
40
+ const wrapperStyle = { width: size, height: size, ...style };
41
+
42
+ return (
43
+ <div
44
+ className={cn("inline-flex items-center justify-center text-ink", className)}
45
+ style={wrapperStyle}
46
+ role="status"
47
+ aria-label="loading"
48
+ {...props}
49
+ >
50
+ {variant === "spinner" && (
51
+ <motion.svg
52
+ className="h-full w-full"
53
+ viewBox="0 0 24 24"
54
+ fill="none"
55
+ animate={{ rotate: 360 }}
56
+ transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
57
+ >
58
+ <circle
59
+ className="opacity-25"
60
+ cx="12"
61
+ cy="12"
62
+ r="9"
63
+ stroke="currentColor"
64
+ strokeWidth="3"
65
+ />
66
+ <path
67
+ className="opacity-90"
68
+ fill="currentColor"
69
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
70
+ />
71
+ </motion.svg>
72
+ )}
73
+
74
+ {variant === "dots" && (
75
+ <div className="flex h-full w-full items-center justify-center gap-[15%]">
76
+ {[0, 1, 2].map((i) => (
77
+ <motion.span
78
+ key={i}
79
+ className="block rounded-full bg-current"
80
+ style={{ width: "22%", height: "22%" }}
81
+ animate={{ y: [0, -size * 0.2, 0], opacity: [0.4, 1, 0.4] }}
82
+ transition={{
83
+ duration: 0.9,
84
+ repeat: Infinity,
85
+ ease: easeOut,
86
+ delay: i * 0.15,
87
+ }}
88
+ />
89
+ ))}
90
+ </div>
91
+ )}
92
+
93
+ {variant === "bars" && (
94
+ <div className="flex h-full w-full items-end justify-center gap-[15%]">
95
+ {[0, 1, 2, 3].map((i) => (
96
+ <motion.span
97
+ key={i}
98
+ className="block w-[15%] rounded-sm bg-current"
99
+ animate={{ height: ["30%", "100%", "30%"] }}
100
+ transition={{
101
+ duration: 1,
102
+ repeat: Infinity,
103
+ ease: easeOut,
104
+ delay: i * 0.12,
105
+ }}
106
+ />
107
+ ))}
108
+ </div>
109
+ )}
110
+
111
+ {variant === "ring" && (
112
+ <motion.div
113
+ className="h-full w-full rounded-full border-[3px] border-current"
114
+ style={{ borderTopColor: "transparent" }}
115
+ animate={{ rotate: 360 }}
116
+ transition={{ duration: 0.9, repeat: Infinity, ease: "linear" }}
117
+ />
118
+ )}
119
+
120
+ {variant === "pulse" && (
121
+ <motion.span
122
+ className="block rounded-full bg-current"
123
+ style={{ width: "100%", height: "100%" }}
124
+ animate={{ scale: [0.4, 1, 0.4], opacity: [0.3, 1, 0.3] }}
125
+ transition={{ duration: 1.2, repeat: Infinity, ease: easeOut }}
126
+ />
127
+ )}
128
+ </div>
129
+ );
130
+ }
@@ -0,0 +1,2 @@
1
+ export { Loader } from "./Loader";
2
+ export type { LoaderProps, LoaderVariant } from "./Loader";