@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,83 @@
1
+ "use client";
2
+
3
+ import { type ReactNode } from "react";
4
+ import { motion, AnimatePresence } from "framer-motion";
5
+ import { useState } from "react";
6
+ import { cn } from "../../utils/cn";
7
+
8
+ export interface CardHoverItem {
9
+ title: string;
10
+ description: string;
11
+ icon?: ReactNode;
12
+ }
13
+
14
+ export interface CardHoverEffectProps {
15
+ items: CardHoverItem[];
16
+ className?: string;
17
+ }
18
+
19
+ /**
20
+ * CardHoverEffect — a grid of cards where hovering one slides a shared
21
+ * highlight background between cards using framer-motion `layoutId`.
22
+ *
23
+ * The highlight is a single shared element animated via layoutId, so it
24
+ * smoothly translates from card to card as the cursor moves across the grid.
25
+ *
26
+ * @example
27
+ * <CardHoverEffect
28
+ * items={[
29
+ * { title: "生成", description: "一键生成内容" },
30
+ * { title: "分析", description: "智能数据分析" },
31
+ * ]}
32
+ * />
33
+ */
34
+ export function CardHoverEffect({ items, className }: CardHoverEffectProps) {
35
+ const [hovered, setHovered] = useState<number | null>(null);
36
+
37
+ return (
38
+ <div
39
+ className={cn(
40
+ "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3",
41
+ className
42
+ )}
43
+ >
44
+ {items.map((item, idx) => (
45
+ <div
46
+ key={item.title + idx}
47
+ className="group relative"
48
+ onMouseEnter={() => setHovered(idx)}
49
+ onMouseLeave={() => setHovered(null)}
50
+ >
51
+ {/* Shared highlight background animated across cards */}
52
+ <AnimatePresence mode="popLayout">
53
+ {hovered === idx && (
54
+ <motion.span
55
+ layoutId="card-hover-highlight"
56
+ className="absolute inset-0 block rounded-xl bg-surface-2"
57
+ initial={{ opacity: 0 }}
58
+ animate={{ opacity: 1, transition: { duration: 0.15 } }}
59
+ exit={{ opacity: 0, transition: { duration: 0.15 } }}
60
+ />
61
+ )}
62
+ </AnimatePresence>
63
+
64
+ <div className="relative z-10 h-full rounded-xl border border-line p-6 transition-colors duration-200 group-hover:border-line-strong">
65
+ {item.icon && (
66
+ <div className="mb-4 text-ink [&>svg]:h-6 [&>svg]:w-6">
67
+ {item.icon}
68
+ </div>
69
+ )}
70
+ <div className="flex flex-col gap-2">
71
+ <h3 className="text-base font-semibold text-ink">
72
+ {item.title}
73
+ </h3>
74
+ <p className="text-sm leading-relaxed text-ink-2">
75
+ {item.description}
76
+ </p>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ ))}
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,2 @@
1
+ export { CardHoverEffect } from "./CardHoverEffect";
2
+ export type { CardHoverEffectProps, CardHoverItem } from "./CardHoverEffect";
@@ -0,0 +1,63 @@
1
+ "use client";
2
+
3
+ import { useRef, type ReactNode, type MouseEvent } from "react";
4
+ import { motion, useMotionValue, useMotionTemplate } from "framer-motion";
5
+ import { cn } from "../../utils/cn";
6
+
7
+ export interface CardSpotlightProps {
8
+ children: ReactNode;
9
+ className?: string;
10
+ /** Spotlight radius in px (default 300) */
11
+ radius?: number;
12
+ /** Spotlight color (default uses accent token via rgba) */
13
+ color?: string;
14
+ }
15
+
16
+ /**
17
+ * CardSpotlight — card with mouse-following radial spotlight.
18
+ *
19
+ * A subtle radial gradient follows the cursor inside the card,
20
+ * revealing a faint highlight. Based on Aceternity's Card Spotlight
21
+ * pattern, adapted to zmzai's monochrome palette.
22
+ *
23
+ * @example
24
+ * <CardSpotlight radius={250}>
25
+ * <h3>生成 PPT</h3>
26
+ * <p>自动生成演示文稿</p>
27
+ * </CardSpotlight>
28
+ */
29
+ export function CardSpotlight({
30
+ children,
31
+ className,
32
+ radius = 300,
33
+ color = "rgba(0, 0, 0, 0.08)",
34
+ }: CardSpotlightProps) {
35
+ const mouseX = useMotionValue(0);
36
+ const mouseY = useMotionValue(0);
37
+
38
+ function handleMouseMove(e: MouseEvent<HTMLDivElement>) {
39
+ const rect = e.currentTarget.getBoundingClientRect();
40
+ mouseX.set(e.clientX - rect.left);
41
+ mouseY.set(e.clientY - rect.top);
42
+ }
43
+
44
+ const spotlight = useMotionTemplate`radial-gradient(${radius}px circle at ${mouseX}px ${mouseY}px, ${color}, transparent 80%)`;
45
+
46
+ return (
47
+ <div
48
+ onMouseMove={handleMouseMove}
49
+ className={cn(
50
+ "group relative overflow-hidden rounded-xl border border-line bg-bg transition-colors hover:border-line-strong",
51
+ className
52
+ )}
53
+ >
54
+ {/* Spotlight overlay */}
55
+ <motion.div
56
+ className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
57
+ style={{ background: spotlight }}
58
+ />
59
+ {/* Content */}
60
+ <div className="relative z-10">{children}</div>
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,2 @@
1
+ export { CardSpotlight } from "./CardSpotlight";
2
+ export type { CardSpotlightProps } from "./CardSpotlight";
@@ -0,0 +1,79 @@
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 CarouselProps {
8
+ items: ReactNode[];
9
+ className?: string;
10
+ }
11
+
12
+ /**
13
+ * Carousel — minimal slide carousel with arrow nav + dot indicators.
14
+ *
15
+ * Clean monochrome design: chevron buttons appear on sides,
16
+ * dots at bottom. Slides animate with horizontal slide+fade.
17
+ *
18
+ * @example
19
+ * <Carousel items={[<div>A</div>, <div>B</div>, <div>C</div>]} />
20
+ */
21
+ export function Carousel({ items, className }: CarouselProps) {
22
+ const [index, setIndex] = useState(0);
23
+ const [direction, setDirection] = useState(0);
24
+
25
+ const go = (next: number) => {
26
+ setDirection(next > index ? 1 : -1);
27
+ setIndex((next + items.length) % items.length);
28
+ };
29
+
30
+ return (
31
+ <div className={cn("relative", className)}>
32
+ {/* Slide area */}
33
+ <div className="relative overflow-hidden rounded-xl">
34
+ <AnimatePresence mode="wait" custom={direction}>
35
+ <motion.div
36
+ key={index}
37
+ custom={direction}
38
+ initial={{ opacity: 0, x: direction * 60 }}
39
+ animate={{ opacity: 1, x: 0 }}
40
+ exit={{ opacity: 0, x: direction * -60 }}
41
+ transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
42
+ >
43
+ {items[index]}
44
+ </motion.div>
45
+ </AnimatePresence>
46
+
47
+ {/* Prev */}
48
+ <button
49
+ onClick={() => go(index - 1)}
50
+ className="absolute left-2 top-1/2 z-10 flex h-8 w-8 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border border-line bg-bg/80 text-ink-2 backdrop-blur transition-colors hover:border-ink hover:text-ink"
51
+ >
52
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6" /></svg>
53
+ </button>
54
+
55
+ {/* Next */}
56
+ <button
57
+ onClick={() => go(index + 1)}
58
+ className="absolute right-2 top-1/2 z-10 flex h-8 w-8 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border border-line bg-bg/80 text-ink-2 backdrop-blur transition-colors hover:border-ink hover:text-ink"
59
+ >
60
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6" /></svg>
61
+ </button>
62
+ </div>
63
+
64
+ {/* Dots */}
65
+ <div className="mt-4 flex justify-center gap-1.5">
66
+ {items.map((_, i) => (
67
+ <button
68
+ key={i}
69
+ onClick={() => go(i)}
70
+ className={cn(
71
+ "h-1.5 cursor-pointer rounded-full transition-all",
72
+ i === index ? "w-6 bg-ink" : "w-1.5 bg-line hover:bg-ink-3"
73
+ )}
74
+ />
75
+ ))}
76
+ </div>
77
+ </div>
78
+ );
79
+ }
@@ -0,0 +1,2 @@
1
+ export { Carousel } from "./Carousel";
2
+ export type { CarouselProps } from "./Carousel";
@@ -0,0 +1,153 @@
1
+ "use client";
2
+
3
+ import { useState, type ReactNode } from "react";
4
+ import { cn } from "../../utils/cn";
5
+
6
+ export interface CodeBlockProps {
7
+ /** Code content */
8
+ code: string;
9
+ /** Language label shown in header (e.g. "typescript", "bash") */
10
+ language?: string;
11
+ /** File name shown in header (e.g. "useUsers.ts") */
12
+ filename?: string;
13
+ /** Show copy button (default true) */
14
+ copyable?: boolean;
15
+ /** Max height before scroll (default none) */
16
+ maxHeight?: string;
17
+ className?: string;
18
+ }
19
+
20
+ /**
21
+ * CodeBlock — dark surface code display with syntax highlighting + copy.
22
+ *
23
+ * Uses a monochrome syntax highlight scheme (灰阶 + 粗细 + 斜体),
24
+ * matching zmzai's black-white design language. No rainbow colors.
25
+ *
26
+ * @example
27
+ * <CodeBlock code={`const x = 1;`} language="typescript" filename="index.ts" />
28
+ */
29
+ export function CodeBlock({
30
+ code,
31
+ language = "text",
32
+ filename,
33
+ copyable = true,
34
+ maxHeight,
35
+ className,
36
+ }: CodeBlockProps) {
37
+ const [copied, setCopied] = useState(false);
38
+
39
+ const handleCopy = async () => {
40
+ try {
41
+ await navigator.clipboard.writeText(code);
42
+ setCopied(true);
43
+ setTimeout(() => setCopied(false), 2000);
44
+ } catch {
45
+ // clipboard not available
46
+ }
47
+ };
48
+
49
+ return (
50
+ <div
51
+ className={cn(
52
+ "overflow-hidden rounded-xl border border-dark-line bg-dark-bg shadow-md",
53
+ className
54
+ )}
55
+ >
56
+ {/* Header */}
57
+ <div className="flex items-center justify-between border-b border-dark-line bg-dark-surface px-3.5 py-2">
58
+ <div className="flex items-center gap-2">
59
+ {/* Traffic lights */}
60
+ <div className="flex gap-1.5">
61
+ <span className="h-2.5 w-2.5 rounded-full bg-[#ff5f56]" />
62
+ <span className="h-2.5 w-2.5 rounded-full bg-[#ffbd2e]" />
63
+ <span className="h-2.5 w-2.5 rounded-full bg-[#27c93f]" />
64
+ </div>
65
+ <span className="ml-1 font-mono text-xs font-medium text-dark-ink/70">
66
+ {filename || language}
67
+ </span>
68
+ </div>
69
+ {copyable && (
70
+ <button
71
+ onClick={handleCopy}
72
+ className="font-mono text-[11px] text-ink-3 transition-colors hover:text-dark-ink"
73
+ >
74
+ {copied ? "已复制 ✓" : "复制"}
75
+ </button>
76
+ )}
77
+ </div>
78
+
79
+ {/* Body */}
80
+ <div
81
+ className="overflow-x-auto p-3.5"
82
+ style={maxHeight ? { maxHeight, overflowY: "auto" } : undefined}
83
+ >
84
+ <pre className="font-mono text-[13px] leading-relaxed text-dark-ink">
85
+ <code>{highlightCode(code, language)}</code>
86
+ </pre>
87
+ </div>
88
+ </div>
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Lightweight monochrome syntax highlighter.
94
+ * Uses spans with Tailwind classes: keyword=bold white, string=light gray,
95
+ * comment=medium gray italic, function=light gray italic.
96
+ * Falls back to plain text for unrecognized languages.
97
+ */
98
+ function highlightCode(code: string, _language: string): ReactNode {
99
+ // Simple regex-based highlighting for common patterns
100
+ // keyword detection
101
+ const keywordRe =
102
+ /\b(import|export|from|const|let|var|function|return|if|else|for|while|class|interface|type|extends|implements|new|async|await|try|catch|throw|typeof|instanceof|void|null|undefined|true|false|this|super|default|switch|case|break|continue|do|in|of|as|enum|public|private|protected|readonly|static|get|set|namespace|declare|abstract|yield|delete|with)\b/g;
103
+ const stringRe = /(["'`])((?:\\.|(?!\1).)*)\1/g;
104
+ const commentRe = /(\/\/.*$|\/\*[\s\S]*?\*\/)/gm;
105
+
106
+ // Escape HTML first
107
+ const escaped = code
108
+ .replace(/&/g, "&amp;")
109
+ .replace(/</g, "&lt;")
110
+ .replace(/>/g, "&gt;");
111
+
112
+ // Apply highlights with placeholder tokens to avoid overlap
113
+ const tokens: { type: string; text: string }[] = [];
114
+ let working = escaped;
115
+
116
+ // Comments first (highest priority)
117
+ working = working.replace(commentRe, (match) => {
118
+ tokens.push({ type: "comment", text: match });
119
+ return `\x00${tokens.length - 1}\x00`;
120
+ });
121
+
122
+ // Strings
123
+ working = working.replace(stringRe, (match) => {
124
+ tokens.push({ type: "string", text: match });
125
+ return `\x00${tokens.length - 1}\x00`;
126
+ });
127
+
128
+ // Keywords
129
+ working = working.replace(keywordRe, (match) => {
130
+ tokens.push({ type: "keyword", text: match });
131
+ return `\x00${tokens.length - 1}\x00`;
132
+ });
133
+
134
+ // Rebuild with spans
135
+ const parts = working.split(/\x00(\d+)\x00/);
136
+ return parts.map((part, i) => {
137
+ if (i % 2 === 1) {
138
+ const token = tokens[parseInt(part, 10)];
139
+ const cls =
140
+ token.type === "keyword"
141
+ ? "font-semibold text-dark-ink"
142
+ : token.type === "string"
143
+ ? "text-dark-ink/50"
144
+ : "text-dark-ink/40 italic";
145
+ return (
146
+ <span key={i} className={cls}>
147
+ {token.text}
148
+ </span>
149
+ );
150
+ }
151
+ return <span key={i}>{part}</span>;
152
+ });
153
+ }
@@ -0,0 +1,2 @@
1
+ export { CodeBlock } from "./CodeBlock";
2
+ export type { CodeBlockProps } from "./CodeBlock";
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ import { useRef, useState, type ReactNode, type MouseEvent } from "react";
4
+ import {
5
+ motion,
6
+ useMotionValue,
7
+ useSpring,
8
+ useTransform,
9
+ } from "framer-motion";
10
+ import { cn } from "../../utils/cn";
11
+
12
+ export interface CometCardProps {
13
+ children: ReactNode;
14
+ className?: string;
15
+ }
16
+
17
+ const MAX_TILT = 8; // deg
18
+
19
+ /**
20
+ * CometCard — a 3D-perspective tilt card with a "comet" light streak that
21
+ * shoots across the card on hover.
22
+ *
23
+ * The card tilts toward the cursor (CSS `perspective` + rotateX/rotateY).
24
+ * On each mouse enter a small gradient dot animates diagonally across the
25
+ * surface via framer-motion keyframes, leaving a light trail. A `key`
26
+ * counter remounts the comet so the streak replays on every entry.
27
+ *
28
+ * @example
29
+ * <CometCard className="p-8">
30
+ * <h3 className="text-white">悬停触发流星</h3>
31
+ * </CometCard>
32
+ */
33
+ export function CometCard({ children, className }: CometCardProps) {
34
+ const ref = useRef<HTMLDivElement>(null);
35
+ const [streakKey, setStreakKey] = useState(0);
36
+
37
+ const mx = useMotionValue(0);
38
+ const my = useMotionValue(0);
39
+
40
+ const rotateX = useSpring(
41
+ useTransform(my, [-0.5, 0.5], [MAX_TILT, -MAX_TILT]),
42
+ { stiffness: 150, damping: 20 }
43
+ );
44
+ const rotateY = useSpring(
45
+ useTransform(mx, [-0.5, 0.5], [-MAX_TILT, MAX_TILT]),
46
+ { stiffness: 150, damping: 20 }
47
+ );
48
+
49
+ function handleMouseMove(e: MouseEvent<HTMLDivElement>) {
50
+ const el = ref.current;
51
+ if (!el) return;
52
+ const rect = el.getBoundingClientRect();
53
+ mx.set((e.clientX - rect.left) / rect.width - 0.5);
54
+ my.set((e.clientY - rect.top) / rect.height - 0.5);
55
+ }
56
+
57
+ function handleMouseEnter() {
58
+ // Remount the comet so the streak replays on each entry.
59
+ setStreakKey((k) => k + 1);
60
+ }
61
+
62
+ function handleMouseLeave() {
63
+ mx.set(0);
64
+ my.set(0);
65
+ }
66
+
67
+ return (
68
+ <div
69
+ ref={ref}
70
+ onMouseMove={handleMouseMove}
71
+ onMouseEnter={handleMouseEnter}
72
+ onMouseLeave={handleMouseLeave}
73
+ style={{ perspective: 1000 }}
74
+ className={cn("group relative", className)}
75
+ >
76
+ <motion.div
77
+ style={{ rotateX, rotateY, transformStyle: "preserve-3d" }}
78
+ className="relative overflow-hidden rounded-xl border border-dark-line bg-dark-bg"
79
+ >
80
+ {/* Comet streak — replays on every mouse-enter via streakKey */}
81
+ <motion.span
82
+ key={streakKey}
83
+ className="pointer-events-none absolute z-20 h-24 w-24 rounded-full blur-2xl"
84
+ style={{
85
+ background:
86
+ "radial-gradient(circle, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.25) 45%, transparent 70%)",
87
+ }}
88
+ initial={{ x: "-25%", y: "-25%", opacity: 0 }}
89
+ animate={{
90
+ x: ["-25%", "125%"],
91
+ y: ["-25%", "125%"],
92
+ opacity: [0, 1, 1, 0],
93
+ }}
94
+ transition={{ duration: 1.1, ease: "easeInOut", times: [0, 0.2, 0.8, 1] }}
95
+ />
96
+
97
+ <div
98
+ className="relative z-10 p-5"
99
+ style={{ transform: "translateZ(40px)" }}
100
+ >
101
+ {children}
102
+ </div>
103
+ </motion.div>
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,2 @@
1
+ export { CometCard } from "./CometCard";
2
+ export type { CometCardProps } from "./CometCard";
@@ -0,0 +1,128 @@
1
+ "use client";
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useRef,
7
+ useState,
8
+ type HTMLAttributes,
9
+ type PointerEvent as ReactPointerEvent,
10
+ type ReactNode,
11
+ } from "react";
12
+ import { cn } from "../../utils/cn";
13
+
14
+ export interface CompareProps extends HTMLAttributes<HTMLDivElement> {
15
+ /** Shown on the left (the "before") */
16
+ before: ReactNode;
17
+ /** Shown on the right (the "after") */
18
+ after: ReactNode;
19
+ /** Initial position of the handle as a percentage (0–100) */
20
+ initial?: number;
21
+ }
22
+
23
+ /**
24
+ * Compare — a draggable before/after slider.
25
+ *
26
+ * Renders `before` and `after` layered in the same box. A vertical drag
27
+ * handle clips the `before` layer to the left of the handle; the `after`
28
+ * layer shows through on the right. The `before` content is sized to the
29
+ * full container width so it does not squish as the handle moves.
30
+ *
31
+ * @example
32
+ * <Compare before={<img src="/old.png" />} after={<img src="/new.png" />} />
33
+ */
34
+ export function Compare({
35
+ before,
36
+ after,
37
+ initial = 50,
38
+ className,
39
+ ...props
40
+ }: CompareProps) {
41
+ const [pos, setPos] = useState(initial);
42
+ const [width, setWidth] = useState(0);
43
+ const containerRef = useRef<HTMLDivElement>(null);
44
+ const dragging = useRef(false);
45
+
46
+ useEffect(() => {
47
+ const el = containerRef.current;
48
+ if (!el) return;
49
+ const measure = () => setWidth(el.getBoundingClientRect().width);
50
+ measure();
51
+ const ro = new ResizeObserver(measure);
52
+ ro.observe(el);
53
+ return () => ro.disconnect();
54
+ }, []);
55
+
56
+ const updateFromClientX = useCallback((clientX: number) => {
57
+ const el = containerRef.current;
58
+ if (!el) return;
59
+ const rect = el.getBoundingClientRect();
60
+ const pct = ((clientX - rect.left) / rect.width) * 100;
61
+ setPos(Math.min(100, Math.max(0, pct)));
62
+ }, []);
63
+
64
+ const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
65
+ dragging.current = true;
66
+ (e.target as HTMLElement).setPointerCapture?.(e.pointerId);
67
+ updateFromClientX(e.clientX);
68
+ };
69
+
70
+ const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
71
+ if (!dragging.current) return;
72
+ updateFromClientX(e.clientX);
73
+ };
74
+
75
+ const onPointerUp = () => {
76
+ dragging.current = false;
77
+ };
78
+
79
+ return (
80
+ <div
81
+ ref={containerRef}
82
+ onPointerDown={onPointerDown}
83
+ onPointerMove={onPointerMove}
84
+ onPointerUp={onPointerUp}
85
+ onPointerLeave={onPointerUp}
86
+ className={cn(
87
+ "relative select-none overflow-hidden rounded-xl border border-line",
88
+ className
89
+ )}
90
+ style={{ touchAction: "none" }}
91
+ {...props}
92
+ >
93
+ {/* After (base layer, full width) */}
94
+ <div className="absolute inset-0">{after}</div>
95
+
96
+ {/* Before (clipped to the left of handle) */}
97
+ <div
98
+ className="absolute inset-y-0 left-0 overflow-hidden"
99
+ style={{ width: `${pos}%` }}
100
+ >
101
+ <div className="h-full" style={{ width: width ? `${width}px` : "100%" }}>
102
+ {before}
103
+ </div>
104
+ </div>
105
+
106
+ {/* Drag handle */}
107
+ <div
108
+ className="absolute inset-y-0 z-10 flex w-0.5 cursor-ew-resize items-center justify-center bg-ink"
109
+ style={{ left: `${pos}%`, transform: "translateX(-50%)" }}
110
+ >
111
+ <div className="flex h-9 w-9 items-center justify-center rounded-full border border-line bg-white text-ink shadow-md">
112
+ <svg
113
+ className="h-4 w-4"
114
+ viewBox="0 0 24 24"
115
+ fill="none"
116
+ stroke="currentColor"
117
+ strokeWidth="2"
118
+ strokeLinecap="round"
119
+ strokeLinejoin="round"
120
+ >
121
+ <polyline points="15 18 9 12 15 6" />
122
+ <polyline points="9 18 15 12 9 6" transform="translate(6 0)" />
123
+ </svg>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ );
128
+ }
@@ -0,0 +1,2 @@
1
+ export { Compare } from "./Compare";
2
+ export type { CompareProps } from "./Compare";