@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.
- package/LICENSE +201 -0
- package/README.md +76 -0
- package/package.json +91 -0
- package/src/brand/index.ts +3 -0
- package/src/brand/logo/Logo.tsx +46 -0
- package/src/brand/logo/favicon.png +0 -0
- package/src/brand/logo/index.ts +2 -0
- package/src/brand/logo/zmzai-logo.png +0 -0
- package/src/brand/wordmark/Wordmark.tsx +48 -0
- package/src/brand/wordmark/index.ts +2 -0
- package/src/components/aceternity-sidebar/AceternitySidebar.tsx +133 -0
- package/src/components/aceternity-sidebar/index.ts +2 -0
- package/src/components/animated-tabs/AnimatedTabs.tsx +60 -0
- package/src/components/animated-tabs/index.ts +2 -0
- package/src/components/animated-tooltip/AnimatedTooltip.tsx +93 -0
- package/src/components/animated-tooltip/index.ts +2 -0
- package/src/components/avatar/Avatar.tsx +59 -0
- package/src/components/avatar/index.ts +2 -0
- package/src/components/badge/Badge.tsx +45 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/bento-grid/BentoGrid.tsx +85 -0
- package/src/components/bento-grid/index.ts +2 -0
- package/src/components/button/Button.tsx +66 -0
- package/src/components/button/button.styles.ts +41 -0
- package/src/components/button/index.ts +4 -0
- package/src/components/card/Card.tsx +87 -0
- package/src/components/card/card.styles.ts +35 -0
- package/src/components/card/index.ts +4 -0
- package/src/components/card-3d/Card3D.tsx +84 -0
- package/src/components/card-3d/index.ts +2 -0
- package/src/components/card-hover-effect/CardHoverEffect.tsx +83 -0
- package/src/components/card-hover-effect/index.ts +2 -0
- package/src/components/card-spotlight/CardSpotlight.tsx +63 -0
- package/src/components/card-spotlight/index.ts +2 -0
- package/src/components/carousel/Carousel.tsx +79 -0
- package/src/components/carousel/index.ts +2 -0
- package/src/components/code-block/CodeBlock.tsx +153 -0
- package/src/components/code-block/index.ts +2 -0
- package/src/components/comet-card/CometCard.tsx +106 -0
- package/src/components/comet-card/index.ts +2 -0
- package/src/components/compare/Compare.tsx +128 -0
- package/src/components/compare/index.ts +2 -0
- package/src/components/dialog/Dialog.tsx +79 -0
- package/src/components/dialog/index.ts +10 -0
- package/src/components/dropdown-menu/DropdownMenu.tsx +76 -0
- package/src/components/dropdown-menu/index.ts +8 -0
- package/src/components/empty-state/EmptyState.tsx +78 -0
- package/src/components/empty-state/index.ts +2 -0
- package/src/components/encrypt-text/EncryptText.tsx +102 -0
- package/src/components/encrypt-text/index.ts +2 -0
- package/src/components/evervault-card/EvervaultCard.tsx +114 -0
- package/src/components/evervault-card/index.ts +2 -0
- package/src/components/expandable-card/ExpandableCard.tsx +91 -0
- package/src/components/expandable-card/index.ts +2 -0
- package/src/components/file-upload/FileUpload.tsx +171 -0
- package/src/components/file-upload/index.ts +2 -0
- package/src/components/flip-words/FlipWords.tsx +70 -0
- package/src/components/flip-words/index.ts +2 -0
- package/src/components/floating-navbar/FloatingNavbar.tsx +82 -0
- package/src/components/floating-navbar/index.ts +2 -0
- package/src/components/focus-cards/FocusCards.tsx +93 -0
- package/src/components/focus-cards/index.ts +2 -0
- package/src/components/glare-card/GlareCard.tsx +66 -0
- package/src/components/glare-card/index.ts +2 -0
- package/src/components/glowing-effect/GlowingEffect.tsx +68 -0
- package/src/components/glowing-effect/index.ts +2 -0
- package/src/components/hover-border-gradient/HoverBorderGradient.tsx +80 -0
- package/src/components/hover-border-gradient/index.ts +2 -0
- package/src/components/index.ts +71 -0
- package/src/components/infinite-moving-cards/InfiniteMovingCards.tsx +84 -0
- package/src/components/infinite-moving-cards/index.ts +2 -0
- package/src/components/input/Input.tsx +35 -0
- package/src/components/input/index.ts +4 -0
- package/src/components/input/input.styles.ts +34 -0
- package/src/components/keyboard/Keyboard.tsx +38 -0
- package/src/components/keyboard/index.ts +2 -0
- package/src/components/layout-grid/LayoutGrid.tsx +121 -0
- package/src/components/layout-grid/index.ts +2 -0
- package/src/components/link-preview/LinkPreview.tsx +121 -0
- package/src/components/link-preview/index.ts +2 -0
- package/src/components/loader/Loader.tsx +130 -0
- package/src/components/loader/index.ts +2 -0
- package/src/components/magnetic-button/MagneticButton.tsx +79 -0
- package/src/components/magnetic-button/index.ts +2 -0
- package/src/components/moving-border/MovingBorder.tsx +72 -0
- package/src/components/moving-border/index.ts +2 -0
- package/src/components/multi-step-loader/MultiStepLoader.tsx +95 -0
- package/src/components/multi-step-loader/index.ts +2 -0
- package/src/components/notch/Notch.tsx +47 -0
- package/src/components/notch/index.ts +2 -0
- package/src/components/select/Select.tsx +100 -0
- package/src/components/select/index.ts +10 -0
- package/src/components/skeleton/Skeleton.tsx +34 -0
- package/src/components/skeleton/index.ts +2 -0
- package/src/components/stateful-button/StatefulButton.tsx +115 -0
- package/src/components/stateful-button/index.ts +2 -0
- package/src/components/sticky-banner/StickyBanner.tsx +74 -0
- package/src/components/sticky-banner/index.ts +2 -0
- package/src/components/sticky-scroll-reveal/StickyScrollReveal.tsx +141 -0
- package/src/components/sticky-scroll-reveal/index.ts +2 -0
- package/src/components/terminal/Terminal.tsx +55 -0
- package/src/components/terminal/index.ts +2 -0
- package/src/components/text-generate/TextGenerate.tsx +59 -0
- package/src/components/text-generate/index.ts +2 -0
- package/src/components/text-hover-effect/TextHoverEffect.tsx +90 -0
- package/src/components/text-hover-effect/index.ts +2 -0
- package/src/components/textarea/Textarea.tsx +39 -0
- package/src/components/textarea/index.ts +2 -0
- package/src/components/timeline/Timeline.tsx +106 -0
- package/src/components/timeline/index.ts +3 -0
- package/src/components/tooltip/Tooltip.tsx +51 -0
- package/src/components/tooltip/index.ts +2 -0
- package/src/components/tooltip-card/TooltipCard.tsx +130 -0
- package/src/components/tooltip-card/index.ts +2 -0
- package/src/components/tracing-beam/TracingBeam.tsx +110 -0
- package/src/components/tracing-beam/index.ts +2 -0
- package/src/components/typewriter/Typewriter.tsx +69 -0
- package/src/components/typewriter/index.ts +2 -0
- package/src/components/vanish-input/VanishInput.tsx +170 -0
- package/src/components/vanish-input/index.ts +2 -0
- package/src/components/wobble-card/WobbleCard.tsx +77 -0
- package/src/components/wobble-card/index.ts +2 -0
- package/src/index.ts +21 -0
- package/src/tokens/fonts.css +42 -0
- package/src/tokens/theme.css +120 -0
- package/src/utils/cn.ts +13 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/motion.ts +47 -0
- package/src/vite-env.d.ts +6 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
useCallback,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
type DragEvent,
|
|
9
|
+
type ChangeEvent,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { motion } from "framer-motion";
|
|
12
|
+
import { cn } from "../../utils/cn";
|
|
13
|
+
|
|
14
|
+
export interface FileUploadProps {
|
|
15
|
+
/** Called with each file selected/dropped */
|
|
16
|
+
onChange: (file: File) => void;
|
|
17
|
+
/** Accepted file types, e.g. ".pdf,image/*" */
|
|
18
|
+
accept?: string;
|
|
19
|
+
/** Allow multiple file selection (onChange still fires per file) */
|
|
20
|
+
multiple?: boolean;
|
|
21
|
+
className?: string;
|
|
22
|
+
/** Custom inner content; defaults to the upload prompt */
|
|
23
|
+
children?: ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* FileUpload — drag-and-drop upload zone with a dotted grid backdrop.
|
|
28
|
+
*
|
|
29
|
+
* Renders a clickable drop area. Supports drag-over highlight and
|
|
30
|
+
* click-to-browse via a hidden `<input type="file">`. Based on Aceternity's
|
|
31
|
+
* File Upload, adapted to the zmzai monochrome palette.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* <FileUpload accept="image/*" onChange={(f) => upload(f)}>
|
|
35
|
+
* <span>拖拽或点击上传</span>
|
|
36
|
+
* </FileUpload>
|
|
37
|
+
*/
|
|
38
|
+
export function FileUpload({
|
|
39
|
+
onChange,
|
|
40
|
+
accept,
|
|
41
|
+
multiple,
|
|
42
|
+
className,
|
|
43
|
+
children,
|
|
44
|
+
}: FileUploadProps) {
|
|
45
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
46
|
+
const [dragging, setDragging] = useState(false);
|
|
47
|
+
|
|
48
|
+
const handleFiles = useCallback(
|
|
49
|
+
(files: FileList | null) => {
|
|
50
|
+
if (!files || files.length === 0) return;
|
|
51
|
+
if (multiple) {
|
|
52
|
+
Array.from(files).forEach((f) => onChange(f));
|
|
53
|
+
} else {
|
|
54
|
+
onChange(files[0]);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
[onChange, multiple]
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
e.stopPropagation();
|
|
63
|
+
setDragging(true);
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
e.stopPropagation();
|
|
69
|
+
setDragging(false);
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const handleDrop = useCallback(
|
|
73
|
+
(e: DragEvent<HTMLDivElement>) => {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
e.stopPropagation();
|
|
76
|
+
setDragging(false);
|
|
77
|
+
handleFiles(e.dataTransfer.files);
|
|
78
|
+
},
|
|
79
|
+
[handleFiles]
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const handleChange = useCallback(
|
|
83
|
+
(e: ChangeEvent<HTMLInputElement>) => {
|
|
84
|
+
handleFiles(e.target.files);
|
|
85
|
+
// reset so selecting the same file again still fires onChange
|
|
86
|
+
e.target.value = "";
|
|
87
|
+
},
|
|
88
|
+
[handleFiles]
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<motion.div
|
|
93
|
+
role="button"
|
|
94
|
+
tabIndex={0}
|
|
95
|
+
onClick={() => inputRef.current?.click()}
|
|
96
|
+
onKeyDown={(e) => {
|
|
97
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
inputRef.current?.click();
|
|
100
|
+
}
|
|
101
|
+
}}
|
|
102
|
+
onDragOver={handleDragOver}
|
|
103
|
+
onDragLeave={handleDragLeave}
|
|
104
|
+
onDrop={handleDrop}
|
|
105
|
+
whileHover={{ scale: 1.005 }}
|
|
106
|
+
whileTap={{ scale: 0.995 }}
|
|
107
|
+
className={cn(
|
|
108
|
+
"relative flex min-h-48 w-full cursor-pointer flex-col items-center justify-center gap-4 overflow-hidden rounded-xl border-2 border-dashed p-8 text-center transition-colors duration-200",
|
|
109
|
+
"border-line bg-surface hover:border-ink/40 hover:bg-surface-2",
|
|
110
|
+
dragging && "border-ink bg-surface-2",
|
|
111
|
+
className
|
|
112
|
+
)}
|
|
113
|
+
>
|
|
114
|
+
{/* Dotted grid background */}
|
|
115
|
+
<div
|
|
116
|
+
className="pointer-events-none absolute inset-0 opacity-60"
|
|
117
|
+
style={{
|
|
118
|
+
backgroundImage:
|
|
119
|
+
"radial-gradient(currentColor 1px, transparent 1px)",
|
|
120
|
+
backgroundSize: "16px 16px",
|
|
121
|
+
color: "rgba(0,0,0,0.05)",
|
|
122
|
+
}}
|
|
123
|
+
/>
|
|
124
|
+
|
|
125
|
+
{/* Content */}
|
|
126
|
+
<div className="relative z-10 flex flex-col items-center gap-3">
|
|
127
|
+
{children ?? (
|
|
128
|
+
<>
|
|
129
|
+
<UploadIcon />
|
|
130
|
+
<div className="space-y-1">
|
|
131
|
+
<p className="text-sm font-medium text-ink">
|
|
132
|
+
拖拽文件到此处,或点击浏览
|
|
133
|
+
</p>
|
|
134
|
+
<p className="text-xs text-ink-3">
|
|
135
|
+
{accept ? `支持 ${accept}` : "支持任意类型文件"}
|
|
136
|
+
</p>
|
|
137
|
+
</div>
|
|
138
|
+
</>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<input
|
|
143
|
+
ref={inputRef}
|
|
144
|
+
type="file"
|
|
145
|
+
accept={accept}
|
|
146
|
+
multiple={multiple}
|
|
147
|
+
onChange={handleChange}
|
|
148
|
+
className="hidden"
|
|
149
|
+
/>
|
|
150
|
+
</motion.div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function UploadIcon() {
|
|
155
|
+
return (
|
|
156
|
+
<svg
|
|
157
|
+
className="h-8 w-8 text-ink-2"
|
|
158
|
+
viewBox="0 0 24 24"
|
|
159
|
+
fill="none"
|
|
160
|
+
stroke="currentColor"
|
|
161
|
+
strokeWidth={1.5}
|
|
162
|
+
strokeLinecap="round"
|
|
163
|
+
strokeLinejoin="round"
|
|
164
|
+
aria-hidden="true"
|
|
165
|
+
>
|
|
166
|
+
<path d="M12 3v12" />
|
|
167
|
+
<path d="m7 8 5-5 5 5" />
|
|
168
|
+
<path d="M5 21h14" />
|
|
169
|
+
</svg>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, type HTMLAttributes } from "react";
|
|
4
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
5
|
+
import { cn } from "../../utils/cn";
|
|
6
|
+
|
|
7
|
+
export interface FlipWordsProps extends HTMLAttributes<HTMLSpanElement> {
|
|
8
|
+
/** Words to cycle through */
|
|
9
|
+
words: string[];
|
|
10
|
+
/** Milliseconds each word stays before flipping */
|
|
11
|
+
duration?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* FlipWords — cycles through words with a 3D flip animation.
|
|
16
|
+
*
|
|
17
|
+
* The current word rotates up and out on the X axis while the next word
|
|
18
|
+
* rotates in from the bottom. Keep this container inline for natural flow.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* <FlipWords words={["faster", "smoother", "minimal"]} duration={3000} />
|
|
22
|
+
*/
|
|
23
|
+
export function FlipWords({
|
|
24
|
+
words,
|
|
25
|
+
duration = 3000,
|
|
26
|
+
className,
|
|
27
|
+
...props
|
|
28
|
+
}: FlipWordsProps) {
|
|
29
|
+
const [index, setIndex] = useState(0);
|
|
30
|
+
const [current, setCurrent] = useState(0);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (words.length <= 1) return;
|
|
34
|
+
const id = setInterval(() => {
|
|
35
|
+
setCurrent((prev) => (prev + 1) % words.length);
|
|
36
|
+
}, duration);
|
|
37
|
+
return () => clearInterval(id);
|
|
38
|
+
}, [words.length, duration]);
|
|
39
|
+
|
|
40
|
+
// keep `index` in sync with `current` for exit animations
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
setIndex(current);
|
|
43
|
+
}, [current]);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<span
|
|
47
|
+
className={cn("relative inline-block", className)}
|
|
48
|
+
style={{ perspective: "1000px" }}
|
|
49
|
+
{...props}
|
|
50
|
+
>
|
|
51
|
+
<span className="invisible whitespace-pre">
|
|
52
|
+
{words.reduce((a, b) => (a.length >= b.length ? a : b), "")}
|
|
53
|
+
</span>
|
|
54
|
+
<span className="absolute inset-0 inline-flex items-center justify-start">
|
|
55
|
+
<AnimatePresence mode="wait">
|
|
56
|
+
<motion.span
|
|
57
|
+
key={current}
|
|
58
|
+
className="inline-block will-change-transform"
|
|
59
|
+
initial={{ rotateX: -90, opacity: 0, transformOrigin: "bottom" }}
|
|
60
|
+
animate={{ rotateX: 0, opacity: 1 }}
|
|
61
|
+
exit={{ rotateX: 90, opacity: 0, transformOrigin: "top" }}
|
|
62
|
+
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
|
63
|
+
>
|
|
64
|
+
{words[index]}
|
|
65
|
+
</motion.span>
|
|
66
|
+
</AnimatePresence>
|
|
67
|
+
</span>
|
|
68
|
+
</span>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type ReactNode } from "react";
|
|
4
|
+
import {
|
|
5
|
+
motion,
|
|
6
|
+
useMotionValueEvent,
|
|
7
|
+
useScroll,
|
|
8
|
+
type Variants,
|
|
9
|
+
} from "framer-motion";
|
|
10
|
+
import { cn } from "../../utils/cn";
|
|
11
|
+
|
|
12
|
+
export interface FloatingNavbarProps {
|
|
13
|
+
/** Nav items rendered inside the pill container */
|
|
14
|
+
children?: ReactNode;
|
|
15
|
+
className?: string;
|
|
16
|
+
/** Position from top in pixels when visible (default 16) */
|
|
17
|
+
topOffset?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const visible: Variants = {
|
|
21
|
+
hidden: {
|
|
22
|
+
y: -100,
|
|
23
|
+
opacity: 0,
|
|
24
|
+
transition: {
|
|
25
|
+
duration: 0.3,
|
|
26
|
+
ease: [0.16, 1, 0.3, 1],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
visible: {
|
|
30
|
+
y: 0,
|
|
31
|
+
opacity: 1,
|
|
32
|
+
transition: {
|
|
33
|
+
duration: 0.3,
|
|
34
|
+
ease: [0.16, 1, 0.3, 1],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* FloatingNavbar — sticky top navbar that hides on scroll down, shows on scroll up.
|
|
41
|
+
*
|
|
42
|
+
* Fixed position, centered, pill-shaped container with backdrop-blur and shadow.
|
|
43
|
+
* Uses framer-motion `useScroll` + `useMotionValueEvent` to detect scroll direction.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* <FloatingNavbar>
|
|
47
|
+
* <a href="#home">Home</a>
|
|
48
|
+
* <a href="#about">About</a>
|
|
49
|
+
* </FloatingNavbar>
|
|
50
|
+
*/
|
|
51
|
+
export function FloatingNavbar({
|
|
52
|
+
children,
|
|
53
|
+
className,
|
|
54
|
+
topOffset = 16,
|
|
55
|
+
}: FloatingNavbarProps) {
|
|
56
|
+
const { scrollY } = useScroll();
|
|
57
|
+
const [hidden, setHidden] = useState(false);
|
|
58
|
+
|
|
59
|
+
useMotionValueEvent(scrollY, "change", (latest) => {
|
|
60
|
+
const previous = scrollY.getPrevious() ?? 0;
|
|
61
|
+
// Hide when scrolling down (past a small threshold), show when scrolling up
|
|
62
|
+
if (latest > previous && latest > 80) {
|
|
63
|
+
setHidden(true);
|
|
64
|
+
} else {
|
|
65
|
+
setHidden(false);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<motion.div
|
|
71
|
+
variants={visible}
|
|
72
|
+
animate={hidden ? "hidden" : "visible"}
|
|
73
|
+
style={{ top: topOffset }}
|
|
74
|
+
className={cn(
|
|
75
|
+
"fixed inset-x-0 z-50 mx-auto flex w-fit max-w-[95%] items-center justify-center gap-1 rounded-full border border-line bg-bg/80 px-2 py-2 shadow-lg backdrop-blur-md",
|
|
76
|
+
className
|
|
77
|
+
)}
|
|
78
|
+
>
|
|
79
|
+
{children}
|
|
80
|
+
</motion.div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type ReactNode } from "react";
|
|
4
|
+
import { motion } from "framer-motion";
|
|
5
|
+
import { cn } from "../../utils/cn";
|
|
6
|
+
|
|
7
|
+
export interface FocusCardItem {
|
|
8
|
+
title: string;
|
|
9
|
+
description: string;
|
|
10
|
+
icon?: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FocusCardsProps {
|
|
14
|
+
items: FocusCardItem[];
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* FocusCards — grid of cards where hovering one focuses it (full opacity)
|
|
20
|
+
* and blurs the others (opacity 30%).
|
|
21
|
+
*
|
|
22
|
+
* The hover state is tracked at the container level so siblings react to the
|
|
23
|
+
* hovered card. Uses framer-motion for the opacity transition.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* <FocusCards items={[
|
|
27
|
+
* { title: "速度", description: "毫秒级响应" },
|
|
28
|
+
* { title: "稳定", description: "99.9% 可用" },
|
|
29
|
+
* ]} />
|
|
30
|
+
*/
|
|
31
|
+
export function FocusCards({ items, className }: FocusCardsProps) {
|
|
32
|
+
const [hovered, setHovered] = useState<number | null>(null);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
className={cn(
|
|
37
|
+
"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3",
|
|
38
|
+
className
|
|
39
|
+
)}
|
|
40
|
+
>
|
|
41
|
+
{items.map((item, index) => (
|
|
42
|
+
<FocusCard
|
|
43
|
+
key={index}
|
|
44
|
+
item={item}
|
|
45
|
+
isHovered={hovered === index}
|
|
46
|
+
isAnyHovered={hovered !== null}
|
|
47
|
+
onHover={() => setHovered(index)}
|
|
48
|
+
onLeave={() => setHovered(null)}
|
|
49
|
+
/>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function FocusCard({
|
|
56
|
+
item,
|
|
57
|
+
isHovered,
|
|
58
|
+
isAnyHovered,
|
|
59
|
+
onHover,
|
|
60
|
+
onLeave,
|
|
61
|
+
}: {
|
|
62
|
+
item: FocusCardItem;
|
|
63
|
+
isHovered: boolean;
|
|
64
|
+
isAnyHovered: boolean;
|
|
65
|
+
onHover: () => void;
|
|
66
|
+
onLeave: () => void;
|
|
67
|
+
}) {
|
|
68
|
+
// Blurred when another card is hovered and this one is not
|
|
69
|
+
const dimmed = isAnyHovered && !isHovered;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<motion.div
|
|
73
|
+
onMouseEnter={onHover}
|
|
74
|
+
onMouseLeave={onLeave}
|
|
75
|
+
animate={{ opacity: dimmed ? 0.3 : 1, scale: isHovered ? 1.02 : 1 }}
|
|
76
|
+
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
|
77
|
+
className={cn(
|
|
78
|
+
"group flex flex-col rounded-xl border border-line bg-bg p-5 shadow-sm transition-colors",
|
|
79
|
+
isHovered && "border-line-strong shadow-md"
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
{item.icon && (
|
|
83
|
+
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-surface-2 text-ink">
|
|
84
|
+
{item.icon}
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
<h3 className="text-base font-semibold text-ink">{item.title}</h3>
|
|
88
|
+
<p className="mt-2 text-sm leading-relaxed text-ink-2">
|
|
89
|
+
{item.description}
|
|
90
|
+
</p>
|
|
91
|
+
</motion.div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, type MouseEvent } from "react";
|
|
4
|
+
import { motion, useMotionValue, useMotionTemplate } from "framer-motion";
|
|
5
|
+
import { cn } from "../../utils/cn";
|
|
6
|
+
|
|
7
|
+
export interface GlareCardProps {
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
className?: string;
|
|
10
|
+
/** Color of the glare highlight (default rgba(255,255,255,0.8)) */
|
|
11
|
+
glareColor?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* GlareCard — a dark card with a light "glare" reflection that follows the
|
|
16
|
+
* cursor across its surface.
|
|
17
|
+
*
|
|
18
|
+
* A radial gradient is positioned at the mouse coordinates via
|
|
19
|
+
* `useMotionValue` + `useMotionTemplate`, producing a moving specular
|
|
20
|
+
* highlight. Defaults to the dark palette so the glare reads clearly.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* <GlareCard className="p-8">
|
|
24
|
+
* <h3 className="text-white">悬停查看反光</h3>
|
|
25
|
+
* </GlareCard>
|
|
26
|
+
*/
|
|
27
|
+
export function GlareCard({
|
|
28
|
+
children,
|
|
29
|
+
className,
|
|
30
|
+
glareColor = "rgba(255, 255, 255, 0.8)",
|
|
31
|
+
}: GlareCardProps) {
|
|
32
|
+
const mouseX = useMotionValue(-200);
|
|
33
|
+
const mouseY = useMotionValue(-200);
|
|
34
|
+
|
|
35
|
+
const glare = useMotionTemplate`radial-gradient(circle at ${mouseX}px ${mouseY}px, ${glareColor}, transparent 60%)`;
|
|
36
|
+
|
|
37
|
+
function handleMouseMove(e: MouseEvent<HTMLDivElement>) {
|
|
38
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
39
|
+
mouseX.set(e.clientX - rect.left);
|
|
40
|
+
mouseY.set(e.clientY - rect.top);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function handleMouseLeave() {
|
|
44
|
+
mouseX.set(-200);
|
|
45
|
+
mouseY.set(-200);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
onMouseMove={handleMouseMove}
|
|
51
|
+
onMouseLeave={handleMouseLeave}
|
|
52
|
+
className={cn(
|
|
53
|
+
"group relative overflow-hidden rounded-xl border border-dark-line bg-dark-bg",
|
|
54
|
+
className
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
{/* Glare overlay */}
|
|
58
|
+
<motion.div
|
|
59
|
+
className="pointer-events-none absolute inset-0 z-20 opacity-0 mix-blend-overlay transition-opacity duration-300 group-hover:opacity-100"
|
|
60
|
+
style={{ background: glare }}
|
|
61
|
+
/>
|
|
62
|
+
{/* Content sits above the glare */}
|
|
63
|
+
<div className="relative z-10">{children}</div>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, type MouseEvent } from "react";
|
|
4
|
+
import { motion, useMotionValue, useMotionTemplate } from "framer-motion";
|
|
5
|
+
import { cn } from "../../utils/cn";
|
|
6
|
+
|
|
7
|
+
export interface GlowingEffectProps {
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
className?: string;
|
|
10
|
+
/** Glow color (default "rgba(0,0,0,0.05)") */
|
|
11
|
+
color?: string;
|
|
12
|
+
/** Glow radius in px (default 300) */
|
|
13
|
+
radius?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* GlowingEffect — container with a mouse-following glowing border on hover.
|
|
18
|
+
*
|
|
19
|
+
* A radial gradient glow follows the cursor, lighting up the border region
|
|
20
|
+
* of the container. Unlike CardSpotlight (which tints the surface), this
|
|
21
|
+
* effect emphasizes the border edge with a soft glow. Based on Aceternity's
|
|
22
|
+
* Glowing Effect pattern, adapted to zmzai's monochrome palette.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* <GlowingEffect radius={300}>
|
|
26
|
+
* <div className="p-6">
|
|
27
|
+
* <h3>智能生成</h3>
|
|
28
|
+
* <p>一键创建内容</p>
|
|
29
|
+
* </div>
|
|
30
|
+
* </GlowingEffect>
|
|
31
|
+
*/
|
|
32
|
+
export function GlowingEffect({
|
|
33
|
+
children,
|
|
34
|
+
className,
|
|
35
|
+
color = "rgba(0, 0, 0, 0.05)",
|
|
36
|
+
radius = 300,
|
|
37
|
+
}: GlowingEffectProps) {
|
|
38
|
+
const mouseX = useMotionValue(0);
|
|
39
|
+
const mouseY = useMotionValue(0);
|
|
40
|
+
|
|
41
|
+
function handleMouseMove(e: MouseEvent<HTMLDivElement>) {
|
|
42
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
43
|
+
mouseX.set(e.clientX - rect.left);
|
|
44
|
+
mouseY.set(e.clientY - rect.top);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const glow = useMotionTemplate`radial-gradient(${radius}px circle at ${mouseX}px ${mouseY}px, ${color}, transparent 70%)`;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
onMouseMove={handleMouseMove}
|
|
52
|
+
className={cn(
|
|
53
|
+
"group relative overflow-hidden rounded-xl border border-line bg-bg transition-colors hover:border-line-strong",
|
|
54
|
+
className
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
{/* Glow overlay — follows cursor, reveals on hover */}
|
|
58
|
+
<motion.div
|
|
59
|
+
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
|
60
|
+
style={{ background: glow }}
|
|
61
|
+
/>
|
|
62
|
+
{/* Inner border highlight for the "glowing border" feel */}
|
|
63
|
+
<div className="pointer-events-none absolute inset-0 rounded-xl ring-1 ring-inset ring-ink/0 transition-shadow duration-300 group-hover:ring-ink/5" />
|
|
64
|
+
{/* Content */}
|
|
65
|
+
<div className="relative z-10">{children}</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createElement,
|
|
5
|
+
type ElementType,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
type HTMLAttributes,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { cn } from "../../utils/cn";
|
|
10
|
+
|
|
11
|
+
export interface HoverBorderGradientProps<T extends ElementType = "div">
|
|
12
|
+
extends Omit<HTMLAttributes<HTMLElement>, "children"> {
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
className?: string;
|
|
15
|
+
/** Element type to render (default "div") */
|
|
16
|
+
as?: T;
|
|
17
|
+
/** Outer container className (the element that receives hover) */
|
|
18
|
+
containerClassName?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* HoverBorderGradient — container/badge whose border sweeps a gradient on hover.
|
|
23
|
+
*
|
|
24
|
+
* Pure CSS (no JS animation). The border is a conic-gradient mask that sits
|
|
25
|
+
* transparent until hover, then sweeps from transparent to ink. Based on
|
|
26
|
+
* Aceternity's Hover Border Gradient, adapted to zmzai's monochrome palette —
|
|
27
|
+
* the sweep uses the ink token rather than a rainbow gradient.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* <HoverBorderGradient as="button" className="rounded-full">
|
|
31
|
+
* <span className="px-6 py-2">开始体验</span>
|
|
32
|
+
* </HoverBorderGradient>
|
|
33
|
+
*/
|
|
34
|
+
export function HoverBorderGradient<T extends ElementType = "div">({
|
|
35
|
+
children,
|
|
36
|
+
className,
|
|
37
|
+
containerClassName,
|
|
38
|
+
as,
|
|
39
|
+
...props
|
|
40
|
+
}: HoverBorderGradientProps<T>) {
|
|
41
|
+
const Component = (as ?? "div") as ElementType;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
{/* Scoped keyframes + mask rules (no global @property needed) */}
|
|
46
|
+
<style>{`
|
|
47
|
+
@keyframes hbg-spin {
|
|
48
|
+
to { transform: rotate(360deg); }
|
|
49
|
+
}
|
|
50
|
+
`}</style>
|
|
51
|
+
|
|
52
|
+
<Component
|
|
53
|
+
className={cn(
|
|
54
|
+
"group relative cursor-pointer overflow-hidden rounded-full border border-line bg-bg transition-colors duration-300 hover:border-transparent",
|
|
55
|
+
containerClassName
|
|
56
|
+
)}
|
|
57
|
+
{...props}
|
|
58
|
+
>
|
|
59
|
+
{/* Gradient sweep layer — sits behind content, masked to the border ring.
|
|
60
|
+
Transparent at rest, fades in + spins on hover. */}
|
|
61
|
+
<span
|
|
62
|
+
aria-hidden
|
|
63
|
+
className={cn(
|
|
64
|
+
"pointer-events-none absolute -inset-[100%] opacity-0 transition-opacity duration-300 group-hover:opacity-100",
|
|
65
|
+
className
|
|
66
|
+
)}
|
|
67
|
+
style={{
|
|
68
|
+
background:
|
|
69
|
+
"conic-gradient(from 0deg, transparent 0deg, transparent 270deg, var(--color-ink) 320deg, transparent 360deg)",
|
|
70
|
+
animation: "hbg-spin 2s linear infinite",
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
{/* Inner content with solid background so only the 1px ring shows the sweep */}
|
|
74
|
+
<span className="relative z-10 block rounded-full bg-bg px-6 py-2.5">
|
|
75
|
+
{children}
|
|
76
|
+
</span>
|
|
77
|
+
</Component>
|
|
78
|
+
</>
|
|
79
|
+
);
|
|
80
|
+
}
|