@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,130 @@
|
|
|
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 TooltipCardProps {
|
|
18
|
+
/** The trigger element */
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
/** Avatar image URL */
|
|
21
|
+
avatarSrc?: string;
|
|
22
|
+
/** Person / entity name */
|
|
23
|
+
name: string;
|
|
24
|
+
/** Role / subtitle (e.g. job title) */
|
|
25
|
+
role: string;
|
|
26
|
+
/** Body description */
|
|
27
|
+
description: string;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* TooltipCard — a richer hover card with avatar + title + description.
|
|
33
|
+
*
|
|
34
|
+
* On hover, a card appears below the trigger and follows the mouse X
|
|
35
|
+
* position with spring physics. Aceternity-style, adapted to the zmzai
|
|
36
|
+
* monochrome palette.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* <TooltipCard
|
|
40
|
+
* name="张三"
|
|
41
|
+
* role="前端工程师"
|
|
42
|
+
* description="负责组件库与设计系统。"
|
|
43
|
+
* avatarSrc="/avatar.png"
|
|
44
|
+
* >
|
|
45
|
+
* <span className="underline">@zhangsan</span>
|
|
46
|
+
* </TooltipCard>
|
|
47
|
+
*/
|
|
48
|
+
export function TooltipCard({
|
|
49
|
+
children,
|
|
50
|
+
avatarSrc,
|
|
51
|
+
name,
|
|
52
|
+
role,
|
|
53
|
+
description,
|
|
54
|
+
className,
|
|
55
|
+
}: TooltipCardProps) {
|
|
56
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
57
|
+
const [hovered, setHovered] = useState(false);
|
|
58
|
+
|
|
59
|
+
const mouseX = useMotionValue(0);
|
|
60
|
+
const springConfig = { stiffness: 150, damping: 20 };
|
|
61
|
+
const translateX = useSpring(mouseX, springConfig);
|
|
62
|
+
|
|
63
|
+
const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
|
|
64
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
65
|
+
mouseX.set(e.clientX - rect.left);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
ref={ref}
|
|
71
|
+
onMouseEnter={() => setHovered(true)}
|
|
72
|
+
onMouseLeave={() => setHovered(false)}
|
|
73
|
+
onMouseMove={handleMouseMove}
|
|
74
|
+
className={cn("relative inline-flex", className)}
|
|
75
|
+
>
|
|
76
|
+
{children}
|
|
77
|
+
|
|
78
|
+
<AnimatePresence>
|
|
79
|
+
{hovered && (
|
|
80
|
+
<motion.div
|
|
81
|
+
initial={{ opacity: 0, scale: 0.9, y: -8 }}
|
|
82
|
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
83
|
+
exit={{ opacity: 0, scale: 0.9, y: -8 }}
|
|
84
|
+
transition={{ type: "spring", stiffness: 280, damping: 24 }}
|
|
85
|
+
style={{
|
|
86
|
+
position: "absolute",
|
|
87
|
+
top: "100%",
|
|
88
|
+
left: 0,
|
|
89
|
+
x: translateX,
|
|
90
|
+
translateX: "-50%",
|
|
91
|
+
marginTop: "12px",
|
|
92
|
+
}}
|
|
93
|
+
className="pointer-events-none z-50 w-64 origin-top overflow-hidden rounded-xl border border-line bg-bg shadow-lg"
|
|
94
|
+
>
|
|
95
|
+
{/* accent top border */}
|
|
96
|
+
<div className="h-0.5 w-full bg-ink" />
|
|
97
|
+
<div className="flex gap-3 p-4">
|
|
98
|
+
{/* Avatar */}
|
|
99
|
+
<div className="h-11 w-11 shrink-0 overflow-hidden rounded-full border border-line bg-surface">
|
|
100
|
+
{avatarSrc ? (
|
|
101
|
+
<img
|
|
102
|
+
src={avatarSrc}
|
|
103
|
+
alt={name}
|
|
104
|
+
className="h-full w-full object-cover"
|
|
105
|
+
/>
|
|
106
|
+
) : (
|
|
107
|
+
<div className="flex h-full w-full items-center justify-center text-sm font-semibold text-ink-2">
|
|
108
|
+
{name.charAt(0)}
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
{/* Text */}
|
|
113
|
+
<div className="min-w-0 space-y-1">
|
|
114
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
115
|
+
<p className="truncate text-sm font-semibold text-ink">
|
|
116
|
+
{name}
|
|
117
|
+
</p>
|
|
118
|
+
<p className="shrink-0 text-[11px] text-ink-3">{role}</p>
|
|
119
|
+
</div>
|
|
120
|
+
<p className="text-xs leading-relaxed text-ink-2">
|
|
121
|
+
{description}
|
|
122
|
+
</p>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</motion.div>
|
|
126
|
+
)}
|
|
127
|
+
</AnimatePresence>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useState, type ReactNode } from "react";
|
|
4
|
+
import {
|
|
5
|
+
motion,
|
|
6
|
+
useScroll,
|
|
7
|
+
useSpring,
|
|
8
|
+
useMotionValueEvent,
|
|
9
|
+
} from "framer-motion";
|
|
10
|
+
import { cn } from "../../utils/cn";
|
|
11
|
+
|
|
12
|
+
export interface TracingBeamProps {
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* TracingBeam — vertical SVG beam that traces scroll progress.
|
|
19
|
+
*
|
|
20
|
+
* A thin vertical line sits on the left of the content. As the user
|
|
21
|
+
* scrolls through the section, the beam fills (gray -> ink) to indicate
|
|
22
|
+
* reading progress. Based on Aceternity's Tracing Beam, adapted to the
|
|
23
|
+
* zmzai monochrome palette.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* <TracingBeam>
|
|
27
|
+
* <article>…long-form content…</article>
|
|
28
|
+
* </TracingBeam>
|
|
29
|
+
*/
|
|
30
|
+
export function TracingBeam({ children, className }: TracingBeamProps) {
|
|
31
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
32
|
+
const [svgHeight, setSvgHeight] = useState(0);
|
|
33
|
+
const [progress, setProgress] = useState(0);
|
|
34
|
+
|
|
35
|
+
const { scrollYProgress } = useScroll({
|
|
36
|
+
target: ref,
|
|
37
|
+
offset: ["start 10%", "end 70%"],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const scaleY = useSpring(scrollYProgress, {
|
|
41
|
+
stiffness: 120,
|
|
42
|
+
damping: 30,
|
|
43
|
+
restDelta: 0.001,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
useMotionValueEvent(scrollYProgress, "change", (v) => {
|
|
47
|
+
setProgress(v);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
useMotionValueEvent(scrollYProgress, "change", () => {
|
|
51
|
+
if (ref.current) {
|
|
52
|
+
setSvgHeight(ref.current.offsetHeight);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
ref={ref}
|
|
59
|
+
className={cn(
|
|
60
|
+
"relative mx-auto h-full w-full max-w-2xl",
|
|
61
|
+
className
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
{/* SVG beam */}
|
|
65
|
+
<div className="absolute -left-8 top-3 hidden h-full w-[2px] md:block">
|
|
66
|
+
<svg
|
|
67
|
+
viewBox={`0 0 20 ${svgHeight}`}
|
|
68
|
+
width="20"
|
|
69
|
+
height={svgHeight}
|
|
70
|
+
className="block"
|
|
71
|
+
aria-hidden="true"
|
|
72
|
+
>
|
|
73
|
+
{/* Track */}
|
|
74
|
+
<path
|
|
75
|
+
d={`M 1 0 L 1 ${svgHeight}`}
|
|
76
|
+
fill="none"
|
|
77
|
+
stroke="currentColor"
|
|
78
|
+
strokeWidth={1.5}
|
|
79
|
+
className="text-line"
|
|
80
|
+
strokeOpacity={1}
|
|
81
|
+
/>
|
|
82
|
+
{/* Progress */}
|
|
83
|
+
<motion.path
|
|
84
|
+
d={`M 1 0 L 1 ${svgHeight}`}
|
|
85
|
+
fill="none"
|
|
86
|
+
stroke="currentColor"
|
|
87
|
+
strokeWidth={1.5}
|
|
88
|
+
className="text-ink"
|
|
89
|
+
style={{ scaleY, transformOrigin: "top" }}
|
|
90
|
+
/>
|
|
91
|
+
</svg>
|
|
92
|
+
{/* Dot at the top of the beam */}
|
|
93
|
+
<motion.div
|
|
94
|
+
className="absolute -left-[7px] top-0 h-4 w-4 rounded-full border border-line bg-surface"
|
|
95
|
+
animate={{
|
|
96
|
+
boxShadow:
|
|
97
|
+
progress > 0
|
|
98
|
+
? "0 0 0 2px rgba(0,0,0,0.05)"
|
|
99
|
+
: "none",
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<div className="h-full w-full rounded-full bg-ink" />
|
|
103
|
+
</motion.div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
{/* Content */}
|
|
107
|
+
<div>{children}</div>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, type HTMLAttributes } from "react";
|
|
4
|
+
import { cn } from "../../utils/cn";
|
|
5
|
+
|
|
6
|
+
export interface TypewriterProps extends HTMLAttributes<HTMLSpanElement> {
|
|
7
|
+
/** Text to type out */
|
|
8
|
+
text: string;
|
|
9
|
+
/** Milliseconds per character */
|
|
10
|
+
speed?: number;
|
|
11
|
+
/** Show a blinking caret while typing */
|
|
12
|
+
cursor?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Typewriter — classic character-by-character typing effect.
|
|
17
|
+
*
|
|
18
|
+
* Types out `text` one character at a time at `speed` ms per char.
|
|
19
|
+
* An optional blinking block cursor is shown while typing.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* <Typewriter text="$ npm install @zmzai/theme" speed={50} />
|
|
23
|
+
* <Typewriter text="No cursor" cursor={false} />
|
|
24
|
+
*/
|
|
25
|
+
export function Typewriter({
|
|
26
|
+
text,
|
|
27
|
+
speed = 50,
|
|
28
|
+
cursor = true,
|
|
29
|
+
className,
|
|
30
|
+
...props
|
|
31
|
+
}: TypewriterProps) {
|
|
32
|
+
const [count, setCount] = useState(0);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
setCount(0);
|
|
36
|
+
if (!text) return;
|
|
37
|
+
|
|
38
|
+
const id = setInterval(() => {
|
|
39
|
+
setCount((c) => {
|
|
40
|
+
if (c >= text.length) {
|
|
41
|
+
clearInterval(id);
|
|
42
|
+
return c;
|
|
43
|
+
}
|
|
44
|
+
return c + 1;
|
|
45
|
+
});
|
|
46
|
+
}, speed);
|
|
47
|
+
|
|
48
|
+
return () => clearInterval(id);
|
|
49
|
+
}, [text, speed]);
|
|
50
|
+
|
|
51
|
+
const done = count >= text.length;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<span className={cn("font-mono", className)} {...props}>
|
|
55
|
+
{text.slice(0, count)}
|
|
56
|
+
{cursor && (
|
|
57
|
+
<span
|
|
58
|
+
className={cn(
|
|
59
|
+
"ml-0.5 inline-block w-[2px] self-stretch bg-ink align-middle",
|
|
60
|
+
!done && "animate-pulse"
|
|
61
|
+
)}
|
|
62
|
+
style={{ height: "1em" }}
|
|
63
|
+
>
|
|
64
|
+
|
|
65
|
+
</span>
|
|
66
|
+
)}
|
|
67
|
+
</span>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useState,
|
|
5
|
+
useRef,
|
|
6
|
+
useEffect,
|
|
7
|
+
useCallback,
|
|
8
|
+
type FormEvent,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { motion, AnimatePresence, useAnimate } from "framer-motion";
|
|
11
|
+
import { cn } from "../../utils/cn";
|
|
12
|
+
|
|
13
|
+
export interface VanishInputProps {
|
|
14
|
+
/** Rotating placeholder strings */
|
|
15
|
+
placeholders: string[];
|
|
16
|
+
/** Called on submit (Enter) */
|
|
17
|
+
onSubmit: (value: string) => void;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* VanishInput — search input with a rotating placeholder that "vanishes".
|
|
23
|
+
*
|
|
24
|
+
* While idle, placeholders cycle. Each character of the current placeholder
|
|
25
|
+
* animates upward and fades out (the vanish effect) before the next
|
|
26
|
+
* placeholder appears. Typing hides the placeholder. Based on Aceternity's
|
|
27
|
+
* Vanish Input, adapted to the zmzai palette.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* <VanishInput
|
|
31
|
+
* placeholders={["搜索文档…", "输入关键词…"]}
|
|
32
|
+
* onSubmit={(v) => search(v)}
|
|
33
|
+
* />
|
|
34
|
+
*/
|
|
35
|
+
export function VanishInput({
|
|
36
|
+
placeholders,
|
|
37
|
+
onSubmit,
|
|
38
|
+
className,
|
|
39
|
+
}: VanishInputProps) {
|
|
40
|
+
const [value, setValue] = useState("");
|
|
41
|
+
const [currentPlaceholder, setCurrentPlaceholder] = useState(0);
|
|
42
|
+
const [scope, animate] = useAnimate<HTMLDivElement>();
|
|
43
|
+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
44
|
+
const cyclingRef = useRef(true);
|
|
45
|
+
|
|
46
|
+
const nextPlaceholder = useCallback(() => {
|
|
47
|
+
setCurrentPlaceholder((prev) =>
|
|
48
|
+
prev === placeholders.length - 1 ? 0 : prev + 1
|
|
49
|
+
);
|
|
50
|
+
}, [placeholders.length]);
|
|
51
|
+
|
|
52
|
+
const startVanishing = useCallback(async () => {
|
|
53
|
+
if (!cyclingRef.current || placeholders.length === 0) return;
|
|
54
|
+
cyclingRef.current = false;
|
|
55
|
+
try {
|
|
56
|
+
// vanish: each char moves up & fades out
|
|
57
|
+
await animate(
|
|
58
|
+
"span",
|
|
59
|
+
{ y: -20, opacity: 0 },
|
|
60
|
+
{ duration: 0.3, delay: staggerDelay() }
|
|
61
|
+
);
|
|
62
|
+
// swap to next placeholder
|
|
63
|
+
nextPlaceholder();
|
|
64
|
+
// reset chars below + hidden, then animate in
|
|
65
|
+
await animate(
|
|
66
|
+
"span",
|
|
67
|
+
{ y: 20, opacity: 0 },
|
|
68
|
+
{ duration: 0 }
|
|
69
|
+
);
|
|
70
|
+
await animate(
|
|
71
|
+
"span",
|
|
72
|
+
{ y: 0, opacity: 1 },
|
|
73
|
+
{ duration: 0.3, delay: staggerDelay() }
|
|
74
|
+
);
|
|
75
|
+
} finally {
|
|
76
|
+
cyclingRef.current = true;
|
|
77
|
+
}
|
|
78
|
+
}, [animate, nextPlaceholder, placeholders.length]);
|
|
79
|
+
|
|
80
|
+
// cycle placeholders while idle
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!placeholders.length) return;
|
|
83
|
+
intervalRef.current = setInterval(() => {
|
|
84
|
+
if (value.length === 0) {
|
|
85
|
+
startVanishing();
|
|
86
|
+
}
|
|
87
|
+
}, 3000);
|
|
88
|
+
return () => {
|
|
89
|
+
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
90
|
+
};
|
|
91
|
+
}, [placeholders.length, value.length, startVanishing]);
|
|
92
|
+
|
|
93
|
+
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
if (value.trim().length === 0) return;
|
|
96
|
+
onSubmit(value);
|
|
97
|
+
setValue("");
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const showPlaceholder = value.length === 0 && placeholders.length > 0;
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<form
|
|
104
|
+
onSubmit={handleSubmit}
|
|
105
|
+
className={cn(
|
|
106
|
+
"relative flex w-full max-w-xl items-center overflow-hidden rounded-full border border-line bg-surface px-5 py-2.5 transition-colors focus-within:border-ink/50",
|
|
107
|
+
className
|
|
108
|
+
)}
|
|
109
|
+
>
|
|
110
|
+
{/* Placeholder */}
|
|
111
|
+
{showPlaceholder && (
|
|
112
|
+
<div className="pointer-events-none absolute inset-0 flex items-center overflow-hidden px-5">
|
|
113
|
+
<AnimatePresence mode="wait">
|
|
114
|
+
<motion.div
|
|
115
|
+
key={currentPlaceholder}
|
|
116
|
+
ref={scope}
|
|
117
|
+
className="truncate text-sm text-ink-3"
|
|
118
|
+
>
|
|
119
|
+
{placeholders[currentPlaceholder]
|
|
120
|
+
.split("")
|
|
121
|
+
.map((ch, i) => (
|
|
122
|
+
<motion.span
|
|
123
|
+
key={`ch-${i}`}
|
|
124
|
+
initial={{ opacity: 1, y: 0 }}
|
|
125
|
+
animate={{ opacity: 1, y: 0 }}
|
|
126
|
+
className="inline-block whitespace-pre"
|
|
127
|
+
>
|
|
128
|
+
{ch}
|
|
129
|
+
</motion.span>
|
|
130
|
+
))}
|
|
131
|
+
</motion.div>
|
|
132
|
+
</AnimatePresence>
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
{/* Input */}
|
|
137
|
+
<input
|
|
138
|
+
value={value}
|
|
139
|
+
onChange={(e) => setValue(e.target.value)}
|
|
140
|
+
type="text"
|
|
141
|
+
className="relative z-10 w-full bg-transparent text-sm text-ink outline-none placeholder:text-transparent"
|
|
142
|
+
/>
|
|
143
|
+
|
|
144
|
+
{/* Submit button */}
|
|
145
|
+
<button
|
|
146
|
+
type="submit"
|
|
147
|
+
aria-label="提交"
|
|
148
|
+
className="ml-2 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-ink text-white transition-transform hover:scale-105 active:scale-95"
|
|
149
|
+
>
|
|
150
|
+
<svg
|
|
151
|
+
className="h-3.5 w-3.5"
|
|
152
|
+
viewBox="0 0 24 24"
|
|
153
|
+
fill="none"
|
|
154
|
+
stroke="currentColor"
|
|
155
|
+
strokeWidth={2}
|
|
156
|
+
strokeLinecap="round"
|
|
157
|
+
strokeLinejoin="round"
|
|
158
|
+
>
|
|
159
|
+
<path d="M5 12h14" />
|
|
160
|
+
<path d="m12 5 7 7-7 7" />
|
|
161
|
+
</svg>
|
|
162
|
+
</button>
|
|
163
|
+
</form>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Stagger helper: returns a stagger function usable as the `delay` option. */
|
|
168
|
+
function staggerDelay() {
|
|
169
|
+
return (i: number) => i * 0.03;
|
|
170
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, type ReactNode, type MouseEvent } from "react";
|
|
4
|
+
import {
|
|
5
|
+
motion,
|
|
6
|
+
useMotionValue,
|
|
7
|
+
useSpring,
|
|
8
|
+
useMotionTemplate,
|
|
9
|
+
useTransform,
|
|
10
|
+
} from "framer-motion";
|
|
11
|
+
import { cn } from "../../utils/cn";
|
|
12
|
+
|
|
13
|
+
export interface WobbleCardProps {
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const MAX_ROTATE = 6; // deg
|
|
19
|
+
const MAX_TRANSLATE = 10; // px
|
|
20
|
+
const SPRING = { stiffness: 150, damping: 15, mass: 0.1 };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* WobbleCard — a card that wobbles (translates + rotates + skews slightly)
|
|
24
|
+
* based on the cursor position.
|
|
25
|
+
*
|
|
26
|
+
* Two `useMotionValue`s track the normalized mouse position (-1..1). They
|
|
27
|
+
* are fed through `useTransform` into rotation / translation targets, then
|
|
28
|
+
* smoothed with `useSpring` and composed via `useMotionTemplate` into the
|
|
29
|
+
* final transform string.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* <WobbleCard className="rounded-xl bg-surface p-8">
|
|
33
|
+
* <h3>悬停我</h3>
|
|
34
|
+
* </WobbleCard>
|
|
35
|
+
*/
|
|
36
|
+
export function WobbleCard({ children, className }: WobbleCardProps) {
|
|
37
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
38
|
+
const mx = useMotionValue(0);
|
|
39
|
+
const my = useMotionValue(0);
|
|
40
|
+
|
|
41
|
+
const rotateXRaw = useSpring(useTransform(my, [-0.5, 0.5], [MAX_ROTATE, -MAX_ROTATE]), SPRING);
|
|
42
|
+
const rotateYRaw = useSpring(useTransform(mx, [-0.5, 0.5], [-MAX_ROTATE, MAX_ROTATE]), SPRING);
|
|
43
|
+
const translateXRaw = useSpring(useTransform(mx, [-0.5, 0.5], [-MAX_TRANSLATE, MAX_TRANSLATE]), SPRING);
|
|
44
|
+
const translateYRaw = useSpring(useTransform(my, [-0.5, 0.5], [-MAX_TRANSLATE, MAX_TRANSLATE]), SPRING);
|
|
45
|
+
|
|
46
|
+
const transform = useMotionTemplate`perspective(1000px) rotateX(${rotateXRaw}deg) rotateY(${rotateYRaw}deg) translateX(${translateXRaw}px) translateY(${translateYRaw}px)`;
|
|
47
|
+
|
|
48
|
+
function handleMouseMove(e: MouseEvent<HTMLDivElement>) {
|
|
49
|
+
const el = ref.current;
|
|
50
|
+
if (!el) return;
|
|
51
|
+
const rect = el.getBoundingClientRect();
|
|
52
|
+
const relX = (e.clientX - rect.left) / rect.width - 0.5;
|
|
53
|
+
const relY = (e.clientY - rect.top) / rect.height - 0.5;
|
|
54
|
+
mx.set(relX);
|
|
55
|
+
my.set(relY);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function handleMouseLeave() {
|
|
59
|
+
mx.set(0);
|
|
60
|
+
my.set(0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<motion.div
|
|
65
|
+
ref={ref}
|
|
66
|
+
onMouseMove={handleMouseMove}
|
|
67
|
+
onMouseLeave={handleMouseLeave}
|
|
68
|
+
style={{ transform }}
|
|
69
|
+
className={cn(
|
|
70
|
+
"relative rounded-xl border border-line bg-surface p-8 transition-colors duration-200 hover:border-line-strong",
|
|
71
|
+
className
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
{children}
|
|
75
|
+
</motion.div>
|
|
76
|
+
);
|
|
77
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zmzai/theme — zmzai 全品牌设计系统
|
|
3
|
+
*
|
|
4
|
+
* Usage in your app's globals.css:
|
|
5
|
+
* @import "tailwindcss";
|
|
6
|
+
* @import "@zmzai/theme/tokens";
|
|
7
|
+
* @import "@zmzai/theme/fonts";
|
|
8
|
+
*
|
|
9
|
+
* Usage in components:
|
|
10
|
+
* import { Button, Card, Logo, Wordmark } from "@zmzai/theme";
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Components
|
|
14
|
+
export * from "./components";
|
|
15
|
+
|
|
16
|
+
// Brand assets
|
|
17
|
+
export * from "./brand";
|
|
18
|
+
|
|
19
|
+
// Utils
|
|
20
|
+
export { cn } from "./utils/cn";
|
|
21
|
+
export * as motionPresets from "./utils/motion";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @zmzai/theme — MiSans Font Faces
|
|
3
|
+
*
|
|
4
|
+
* 小米开源字体,免费商用(OFL 协议)。
|
|
5
|
+
* 简体中文全子集,通过 jsDelivr CDN 加载 woff2。
|
|
6
|
+
* font-display: swap 保证加载期间用 fallback,加载完无缝切换。
|
|
7
|
+
*
|
|
8
|
+
* Import in your app's globals.css:
|
|
9
|
+
* @import "@zmzai/theme/fonts";
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
@font-face {
|
|
13
|
+
font-family: 'MiSans';
|
|
14
|
+
src: url('https://cdn.jsdelivr.net/gh/dsrkafuu/misans@main/raw/Normal/woff2/MiSans-Regular.woff2') format('woff2');
|
|
15
|
+
font-weight: 400;
|
|
16
|
+
font-style: normal;
|
|
17
|
+
font-display: swap;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
@font-face {
|
|
21
|
+
font-family: 'MiSans';
|
|
22
|
+
src: url('https://cdn.jsdelivr.net/gh/dsrkafuu/misans@main/raw/Normal/woff2/MiSans-Medium.woff2') format('woff2');
|
|
23
|
+
font-weight: 500;
|
|
24
|
+
font-style: normal;
|
|
25
|
+
font-display: swap;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@font-face {
|
|
29
|
+
font-family: 'MiSans';
|
|
30
|
+
src: url('https://cdn.jsdelivr.net/gh/dsrkafuu/misans@main/raw/Normal/woff2/MiSans-Semibold.woff2') format('woff2');
|
|
31
|
+
font-weight: 600;
|
|
32
|
+
font-style: normal;
|
|
33
|
+
font-display: swap;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@font-face {
|
|
37
|
+
font-family: 'MiSans';
|
|
38
|
+
src: url('https://cdn.jsdelivr.net/gh/dsrkafuu/misans@main/raw/Normal/woff2/MiSans-Bold.woff2') format('woff2');
|
|
39
|
+
font-weight: 700;
|
|
40
|
+
font-style: normal;
|
|
41
|
+
font-display: swap;
|
|
42
|
+
}
|