@smicolon/fasla-ui 0.2.2
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/README.md +141 -0
- package/bin/fasla-ui.mjs +54 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +241 -0
- package/dist/lib/utils.d.ts +9 -0
- package/dist/lib/utils.js +9 -0
- package/dist/motion/index.d.ts +33 -0
- package/dist/motion/index.js +59 -0
- package/dist/registry/index.d.ts +17 -0
- package/dist/registry/index.js +145 -0
- package/dist/tokens/index.d.ts +55 -0
- package/dist/tokens/index.js +34 -0
- package/package.json +97 -0
- package/registry/blocks/app-shell/AppShell.tsx +66 -0
- package/registry/blocks/app-shell/index.ts +1 -0
- package/registry/blocks/data-table/DataTable.tsx +292 -0
- package/registry/blocks/data-table/index.ts +7 -0
- package/registry/blocks/empty-state/EmptyState.test.tsx +120 -0
- package/registry/blocks/empty-state/EmptyState.tsx +164 -0
- package/registry/blocks/empty-state/index.ts +6 -0
- package/registry/blocks/form-section/FormSection.tsx +140 -0
- package/registry/blocks/form-section/index.ts +8 -0
- package/registry/blocks/navbar/Navbar.tsx +190 -0
- package/registry/blocks/navbar/index.ts +16 -0
- package/registry/blocks/page-header/PageHeader.tsx +64 -0
- package/registry/blocks/page-header/index.ts +1 -0
- package/registry/blocks/sidebar/Sidebar.tsx +202 -0
- package/registry/blocks/sidebar/index.ts +16 -0
- package/registry/blocks/stats-card/StatsCard.tsx +134 -0
- package/registry/blocks/stats-card/index.ts +6 -0
- package/registry/effects/animated-gradient/animated-gradient.tsx +136 -0
- package/registry/effects/animated-gradient/index.ts +6 -0
- package/registry/effects/border-beam/border-beam.tsx +132 -0
- package/registry/effects/border-beam/index.ts +6 -0
- package/registry/effects/glow-card/glow-card.tsx +145 -0
- package/registry/effects/glow-card/index.ts +6 -0
- package/registry/effects/shimmer-button/index.ts +1 -0
- package/registry/effects/shimmer-button/shimmer-button.tsx +91 -0
- package/registry/effects/spotlight/index.ts +6 -0
- package/registry/effects/spotlight/spotlight.tsx +162 -0
- package/registry/effects/text-reveal/index.ts +6 -0
- package/registry/effects/text-reveal/text-reveal.tsx +139 -0
- package/registry/effects/typewriter-text/index.ts +6 -0
- package/registry/effects/typewriter-text/typewriter-text.tsx +220 -0
- package/registry/ui/avatar/avatar.tsx +135 -0
- package/registry/ui/avatar/index.ts +1 -0
- package/registry/ui/badge/badge.test.tsx +51 -0
- package/registry/ui/badge/badge.tsx +39 -0
- package/registry/ui/badge/index.ts +1 -0
- package/registry/ui/button/button.test.tsx +87 -0
- package/registry/ui/button/button.tsx +88 -0
- package/registry/ui/button/index.ts +1 -0
- package/registry/ui/card/card.test.tsx +119 -0
- package/registry/ui/card/card.tsx +75 -0
- package/registry/ui/card/index.ts +8 -0
- package/registry/ui/checkbox/checkbox.tsx +113 -0
- package/registry/ui/checkbox/index.ts +1 -0
- package/registry/ui/combobox/combobox.tsx +410 -0
- package/registry/ui/combobox/index.ts +1 -0
- package/registry/ui/input/index.ts +1 -0
- package/registry/ui/input/input.test.tsx +78 -0
- package/registry/ui/input/input.tsx +76 -0
- package/registry/ui/select/index.ts +1 -0
- package/registry/ui/select/select.tsx +94 -0
- package/registry/ui/skeleton/index.ts +7 -0
- package/registry/ui/skeleton/skeleton.test.tsx +104 -0
- package/registry/ui/skeleton/skeleton.tsx +88 -0
- package/registry/ui/switch/index.ts +1 -0
- package/registry/ui/switch/switch.tsx +89 -0
- package/registry/ui/tabs/index.ts +10 -0
- package/registry/ui/tabs/tabs.tsx +150 -0
- package/registry/ui/textarea/index.ts +1 -0
- package/registry/ui/textarea/textarea.tsx +73 -0
- package/registry.json +247 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { cn } from "../../../src/lib/utils"
|
|
5
|
+
|
|
6
|
+
export interface GlowCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
7
|
+
/** Glow color (any CSS color) */
|
|
8
|
+
glowColor?: string
|
|
9
|
+
/** Glow intensity (blur radius in pixels) */
|
|
10
|
+
glowIntensity?: number
|
|
11
|
+
/** Whether glow follows mouse */
|
|
12
|
+
followMouse?: boolean
|
|
13
|
+
/** Whether to show glow on hover only */
|
|
14
|
+
hoverOnly?: boolean
|
|
15
|
+
children: React.ReactNode
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function GlowCard({
|
|
19
|
+
glowColor = "hsl(var(--primary))",
|
|
20
|
+
glowIntensity = 60,
|
|
21
|
+
followMouse = false,
|
|
22
|
+
hoverOnly = true,
|
|
23
|
+
className,
|
|
24
|
+
children,
|
|
25
|
+
...props
|
|
26
|
+
}: GlowCardProps) {
|
|
27
|
+
const cardRef = React.useRef<HTMLDivElement>(null)
|
|
28
|
+
const [mousePosition, setMousePosition] = React.useState({ x: 50, y: 50 })
|
|
29
|
+
const [isHovering, setIsHovering] = React.useState(false)
|
|
30
|
+
|
|
31
|
+
// Check for reduced motion preference
|
|
32
|
+
const prefersReducedMotion = React.useMemo(() => {
|
|
33
|
+
if (typeof window === "undefined") return false
|
|
34
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
35
|
+
}, [])
|
|
36
|
+
|
|
37
|
+
const handleMouseMove = React.useCallback(
|
|
38
|
+
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
39
|
+
if (!followMouse || !cardRef.current || prefersReducedMotion) return
|
|
40
|
+
|
|
41
|
+
const rect = cardRef.current.getBoundingClientRect()
|
|
42
|
+
const x = ((e.clientX - rect.left) / rect.width) * 100
|
|
43
|
+
const y = ((e.clientY - rect.top) / rect.height) * 100
|
|
44
|
+
setMousePosition({ x, y })
|
|
45
|
+
},
|
|
46
|
+
[followMouse, prefersReducedMotion]
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const showGlow = prefersReducedMotion ? false : hoverOnly ? isHovering : true
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
ref={cardRef}
|
|
54
|
+
onMouseMove={handleMouseMove}
|
|
55
|
+
onMouseEnter={() => setIsHovering(true)}
|
|
56
|
+
onMouseLeave={() => setIsHovering(false)}
|
|
57
|
+
className={cn("relative overflow-hidden rounded-xl", className)}
|
|
58
|
+
{...props}
|
|
59
|
+
>
|
|
60
|
+
{/* Glow effect */}
|
|
61
|
+
<div
|
|
62
|
+
className={cn(
|
|
63
|
+
"pointer-events-none absolute -inset-px rounded-xl opacity-0 transition-opacity duration-300",
|
|
64
|
+
showGlow && "opacity-100"
|
|
65
|
+
)}
|
|
66
|
+
style={{
|
|
67
|
+
background: followMouse
|
|
68
|
+
? `radial-gradient(circle at ${mousePosition.x}% ${mousePosition.y}%, ${glowColor}, transparent 70%)`
|
|
69
|
+
: `radial-gradient(circle at 50% 50%, ${glowColor}, transparent 70%)`,
|
|
70
|
+
filter: `blur(${glowIntensity}px)`,
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
|
|
74
|
+
{/* Border glow */}
|
|
75
|
+
<div
|
|
76
|
+
className={cn(
|
|
77
|
+
"pointer-events-none absolute inset-0 rounded-xl opacity-0 transition-opacity duration-300",
|
|
78
|
+
showGlow && "opacity-50"
|
|
79
|
+
)}
|
|
80
|
+
style={{
|
|
81
|
+
background: followMouse
|
|
82
|
+
? `radial-gradient(circle at ${mousePosition.x}% ${mousePosition.y}%, ${glowColor}, transparent 50%)`
|
|
83
|
+
: undefined,
|
|
84
|
+
padding: "1px",
|
|
85
|
+
WebkitMask:
|
|
86
|
+
"linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
|
|
87
|
+
WebkitMaskComposite: "xor",
|
|
88
|
+
maskComposite: "exclude",
|
|
89
|
+
}}
|
|
90
|
+
/>
|
|
91
|
+
|
|
92
|
+
{/* Card content */}
|
|
93
|
+
<div className="relative z-10 rounded-xl border bg-card">
|
|
94
|
+
{children}
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface GlowContainerProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
101
|
+
/** Glow color */
|
|
102
|
+
glowColor?: string
|
|
103
|
+
/** Animation duration in seconds */
|
|
104
|
+
duration?: number
|
|
105
|
+
children: React.ReactNode
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function GlowContainer({
|
|
109
|
+
glowColor = "hsl(var(--primary))",
|
|
110
|
+
duration = 3,
|
|
111
|
+
className,
|
|
112
|
+
children,
|
|
113
|
+
...props
|
|
114
|
+
}: GlowContainerProps) {
|
|
115
|
+
const prefersReducedMotion = React.useMemo(() => {
|
|
116
|
+
if (typeof window === "undefined") return false
|
|
117
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
118
|
+
}, [])
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div
|
|
122
|
+
className={cn("relative overflow-hidden rounded-xl p-px", className)}
|
|
123
|
+
style={
|
|
124
|
+
!prefersReducedMotion
|
|
125
|
+
? {
|
|
126
|
+
background: `linear-gradient(90deg, ${glowColor}, transparent, ${glowColor})`,
|
|
127
|
+
backgroundSize: "200% 100%",
|
|
128
|
+
animation: `glow-slide ${duration}s linear infinite`,
|
|
129
|
+
}
|
|
130
|
+
: { background: glowColor }
|
|
131
|
+
}
|
|
132
|
+
{...props}
|
|
133
|
+
>
|
|
134
|
+
<style dangerouslySetInnerHTML={{ __html: `
|
|
135
|
+
@keyframes glow-slide {
|
|
136
|
+
0% { background-position: 200% 0; }
|
|
137
|
+
100% { background-position: -200% 0; }
|
|
138
|
+
}
|
|
139
|
+
` }} />
|
|
140
|
+
<div className="relative z-10 rounded-[11px] bg-background">
|
|
141
|
+
{children}
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ShimmerButton, type ShimmerButtonProps } from "./shimmer-button"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { useReducedMotion } from "framer-motion"
|
|
5
|
+
import { cn } from "../../../src/lib/utils"
|
|
6
|
+
|
|
7
|
+
export interface ShimmerButtonProps
|
|
8
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
9
|
+
/** Shimmer color */
|
|
10
|
+
shimmerColor?: string
|
|
11
|
+
/** Shimmer size in pixels */
|
|
12
|
+
shimmerSize?: string
|
|
13
|
+
/** Border radius */
|
|
14
|
+
borderRadius?: string
|
|
15
|
+
/** Shimmer duration in seconds */
|
|
16
|
+
shimmerDuration?: string
|
|
17
|
+
/** Background color */
|
|
18
|
+
background?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A button with an animated shimmer effect.
|
|
23
|
+
* The shimmer respects prefers-reduced-motion.
|
|
24
|
+
*/
|
|
25
|
+
export const ShimmerButton = React.forwardRef<
|
|
26
|
+
HTMLButtonElement,
|
|
27
|
+
ShimmerButtonProps
|
|
28
|
+
>(
|
|
29
|
+
(
|
|
30
|
+
{
|
|
31
|
+
shimmerColor = "hsl(var(--primary) / 0.2)",
|
|
32
|
+
shimmerSize = "0.1em",
|
|
33
|
+
shimmerDuration = "2s",
|
|
34
|
+
borderRadius = "0.5rem",
|
|
35
|
+
background = "hsl(var(--primary))",
|
|
36
|
+
className,
|
|
37
|
+
children,
|
|
38
|
+
...props
|
|
39
|
+
},
|
|
40
|
+
ref
|
|
41
|
+
) => {
|
|
42
|
+
const prefersReducedMotion = useReducedMotion()
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<button
|
|
46
|
+
ref={ref}
|
|
47
|
+
className={cn(
|
|
48
|
+
"group relative inline-flex h-10 items-center justify-center overflow-hidden whitespace-nowrap px-6 py-2 text-sm font-medium text-primary-foreground transition-all hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
49
|
+
className
|
|
50
|
+
)}
|
|
51
|
+
style={
|
|
52
|
+
{
|
|
53
|
+
"--shimmer-color": shimmerColor,
|
|
54
|
+
"--shimmer-size": shimmerSize,
|
|
55
|
+
"--shimmer-duration": shimmerDuration,
|
|
56
|
+
"--border-radius": borderRadius,
|
|
57
|
+
"--background": background,
|
|
58
|
+
borderRadius: "var(--border-radius)",
|
|
59
|
+
background: "var(--background)",
|
|
60
|
+
} as React.CSSProperties
|
|
61
|
+
}
|
|
62
|
+
{...props}
|
|
63
|
+
>
|
|
64
|
+
{/* Shimmer effect */}
|
|
65
|
+
{!prefersReducedMotion && (
|
|
66
|
+
<div
|
|
67
|
+
className="absolute inset-0 overflow-hidden"
|
|
68
|
+
style={{ borderRadius: "var(--border-radius)" }}
|
|
69
|
+
>
|
|
70
|
+
<div
|
|
71
|
+
className="animate-shimmer absolute inset-0 -translate-x-full"
|
|
72
|
+
style={{
|
|
73
|
+
background: `linear-gradient(
|
|
74
|
+
90deg,
|
|
75
|
+
transparent 0%,
|
|
76
|
+
var(--shimmer-color) 50%,
|
|
77
|
+
transparent 100%
|
|
78
|
+
)`,
|
|
79
|
+
animationDuration: "var(--shimmer-duration)",
|
|
80
|
+
}}
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
{/* Content */}
|
|
86
|
+
<span className="relative z-10 flex items-center gap-2">{children}</span>
|
|
87
|
+
</button>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
ShimmerButton.displayName = "ShimmerButton"
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { motion, useMotionValue, useSpring, useReducedMotion } from "framer-motion"
|
|
5
|
+
import { cn } from "../../../src/lib/utils"
|
|
6
|
+
|
|
7
|
+
export interface SpotlightProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
8
|
+
/** Spotlight color */
|
|
9
|
+
color?: string
|
|
10
|
+
/** Spotlight size in pixels */
|
|
11
|
+
size?: number
|
|
12
|
+
/** Spotlight blur in pixels */
|
|
13
|
+
blur?: number
|
|
14
|
+
/** Opacity of the spotlight */
|
|
15
|
+
opacity?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Cursor-following spotlight effect.
|
|
20
|
+
* Respects prefers-reduced-motion by disabling the effect.
|
|
21
|
+
*/
|
|
22
|
+
export function Spotlight({
|
|
23
|
+
color = "hsl(var(--primary) / 0.15)",
|
|
24
|
+
size = 400,
|
|
25
|
+
blur = 80,
|
|
26
|
+
opacity = 1,
|
|
27
|
+
className,
|
|
28
|
+
children,
|
|
29
|
+
...props
|
|
30
|
+
}: SpotlightProps) {
|
|
31
|
+
const prefersReducedMotion = useReducedMotion()
|
|
32
|
+
const containerRef = React.useRef<HTMLDivElement>(null)
|
|
33
|
+
|
|
34
|
+
const mouseX = useMotionValue(0)
|
|
35
|
+
const mouseY = useMotionValue(0)
|
|
36
|
+
|
|
37
|
+
const springConfig = { damping: 25, stiffness: 200 }
|
|
38
|
+
const springX = useSpring(mouseX, springConfig)
|
|
39
|
+
const springY = useSpring(mouseY, springConfig)
|
|
40
|
+
|
|
41
|
+
const handleMouseMove = React.useCallback(
|
|
42
|
+
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
43
|
+
if (!containerRef.current || prefersReducedMotion) return
|
|
44
|
+
|
|
45
|
+
const rect = containerRef.current.getBoundingClientRect()
|
|
46
|
+
const x = e.clientX - rect.left - size / 2
|
|
47
|
+
const y = e.clientY - rect.top - size / 2
|
|
48
|
+
|
|
49
|
+
mouseX.set(x)
|
|
50
|
+
mouseY.set(y)
|
|
51
|
+
},
|
|
52
|
+
[mouseX, mouseY, size, prefersReducedMotion]
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if (prefersReducedMotion) {
|
|
56
|
+
return (
|
|
57
|
+
<div className={cn("relative", className)} {...props}>
|
|
58
|
+
{children}
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div
|
|
65
|
+
ref={containerRef}
|
|
66
|
+
onMouseMove={handleMouseMove}
|
|
67
|
+
className={cn("relative overflow-hidden", className)}
|
|
68
|
+
{...props}
|
|
69
|
+
>
|
|
70
|
+
{/* Spotlight */}
|
|
71
|
+
<motion.div
|
|
72
|
+
className="pointer-events-none absolute -z-10 rounded-full"
|
|
73
|
+
style={{
|
|
74
|
+
x: springX,
|
|
75
|
+
y: springY,
|
|
76
|
+
width: size,
|
|
77
|
+
height: size,
|
|
78
|
+
background: `radial-gradient(circle, ${color} 0%, transparent 70%)`,
|
|
79
|
+
filter: `blur(${blur}px)`,
|
|
80
|
+
opacity,
|
|
81
|
+
}}
|
|
82
|
+
/>
|
|
83
|
+
|
|
84
|
+
{/* Content */}
|
|
85
|
+
{children}
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface SpotlightCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
91
|
+
/** Spotlight color */
|
|
92
|
+
spotlightColor?: string
|
|
93
|
+
/** Spotlight size */
|
|
94
|
+
spotlightSize?: number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Card with spotlight effect on hover.
|
|
99
|
+
*/
|
|
100
|
+
export function SpotlightCard({
|
|
101
|
+
spotlightColor = "hsl(var(--primary) / 0.1)",
|
|
102
|
+
spotlightSize = 300,
|
|
103
|
+
className,
|
|
104
|
+
children,
|
|
105
|
+
...props
|
|
106
|
+
}: SpotlightCardProps) {
|
|
107
|
+
const prefersReducedMotion = useReducedMotion()
|
|
108
|
+
const containerRef = React.useRef<HTMLDivElement>(null)
|
|
109
|
+
const [isHovered, setIsHovered] = React.useState(false)
|
|
110
|
+
|
|
111
|
+
const mouseX = useMotionValue(0)
|
|
112
|
+
const mouseY = useMotionValue(0)
|
|
113
|
+
|
|
114
|
+
const handleMouseMove = React.useCallback(
|
|
115
|
+
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
116
|
+
if (!containerRef.current || prefersReducedMotion) return
|
|
117
|
+
|
|
118
|
+
const rect = containerRef.current.getBoundingClientRect()
|
|
119
|
+
const x = e.clientX - rect.left
|
|
120
|
+
const y = e.clientY - rect.top
|
|
121
|
+
|
|
122
|
+
mouseX.set(x)
|
|
123
|
+
mouseY.set(y)
|
|
124
|
+
},
|
|
125
|
+
[mouseX, mouseY, prefersReducedMotion]
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
ref={containerRef}
|
|
131
|
+
onMouseMove={handleMouseMove}
|
|
132
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
133
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
134
|
+
className={cn(
|
|
135
|
+
"relative overflow-hidden rounded-xl border bg-card p-6 shadow",
|
|
136
|
+
className
|
|
137
|
+
)}
|
|
138
|
+
{...props}
|
|
139
|
+
>
|
|
140
|
+
{/* Spotlight */}
|
|
141
|
+
{!prefersReducedMotion && isHovered && (
|
|
142
|
+
<motion.div
|
|
143
|
+
className="pointer-events-none absolute -z-10 rounded-full"
|
|
144
|
+
style={{
|
|
145
|
+
x: mouseX,
|
|
146
|
+
y: mouseY,
|
|
147
|
+
width: spotlightSize,
|
|
148
|
+
height: spotlightSize,
|
|
149
|
+
background: `radial-gradient(circle, ${spotlightColor} 0%, transparent 70%)`,
|
|
150
|
+
transform: "translate(-50%, -50%)",
|
|
151
|
+
}}
|
|
152
|
+
initial={{ opacity: 0 }}
|
|
153
|
+
animate={{ opacity: 1 }}
|
|
154
|
+
exit={{ opacity: 0 }}
|
|
155
|
+
/>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
{/* Content */}
|
|
159
|
+
{children}
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { motion, useReducedMotion, useInView } from "framer-motion"
|
|
5
|
+
import { cn } from "../../../src/lib/utils"
|
|
6
|
+
|
|
7
|
+
export interface TextRevealProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
8
|
+
/** Text to reveal */
|
|
9
|
+
text: string
|
|
10
|
+
/** Delay between each character in seconds */
|
|
11
|
+
delay?: number
|
|
12
|
+
/** Duration of each character animation */
|
|
13
|
+
duration?: number
|
|
14
|
+
/** Whether to trigger on view */
|
|
15
|
+
triggerOnView?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Text reveal animation that reveals text character by character.
|
|
20
|
+
* Respects prefers-reduced-motion by showing text immediately.
|
|
21
|
+
*/
|
|
22
|
+
export function TextReveal({
|
|
23
|
+
text,
|
|
24
|
+
delay = 0.03,
|
|
25
|
+
duration = 0.3,
|
|
26
|
+
triggerOnView = true,
|
|
27
|
+
className,
|
|
28
|
+
...props
|
|
29
|
+
}: TextRevealProps) {
|
|
30
|
+
const prefersReducedMotion = useReducedMotion()
|
|
31
|
+
const ref = React.useRef<HTMLDivElement>(null)
|
|
32
|
+
const isInView = useInView(ref, { once: true, amount: 0.5 })
|
|
33
|
+
|
|
34
|
+
const shouldAnimate = triggerOnView ? isInView : true
|
|
35
|
+
const characters = text.split("")
|
|
36
|
+
|
|
37
|
+
if (prefersReducedMotion) {
|
|
38
|
+
return (
|
|
39
|
+
<div className={className} {...props}>
|
|
40
|
+
{text}
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
ref={ref}
|
|
48
|
+
className={cn("inline-block", className)}
|
|
49
|
+
{...props}
|
|
50
|
+
>
|
|
51
|
+
{characters.map((char, i) => (
|
|
52
|
+
<motion.span
|
|
53
|
+
key={`${char}-${i}`}
|
|
54
|
+
className="inline-block"
|
|
55
|
+
initial={{ opacity: 0, y: 10 }}
|
|
56
|
+
animate={
|
|
57
|
+
shouldAnimate
|
|
58
|
+
? { opacity: 1, y: 0 }
|
|
59
|
+
: { opacity: 0, y: 10 }
|
|
60
|
+
}
|
|
61
|
+
transition={{
|
|
62
|
+
duration,
|
|
63
|
+
delay: i * delay,
|
|
64
|
+
ease: [0.2, 0.65, 0.3, 0.9],
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{char === " " ? "\u00A0" : char}
|
|
68
|
+
</motion.span>
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface WordRevealProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
75
|
+
/** Text to reveal */
|
|
76
|
+
text: string
|
|
77
|
+
/** Delay between each word in seconds */
|
|
78
|
+
delay?: number
|
|
79
|
+
/** Duration of each word animation */
|
|
80
|
+
duration?: number
|
|
81
|
+
/** Whether to trigger on view */
|
|
82
|
+
triggerOnView?: boolean
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Word-by-word reveal animation.
|
|
87
|
+
*/
|
|
88
|
+
export function WordReveal({
|
|
89
|
+
text,
|
|
90
|
+
delay = 0.1,
|
|
91
|
+
duration = 0.4,
|
|
92
|
+
triggerOnView = true,
|
|
93
|
+
className,
|
|
94
|
+
...props
|
|
95
|
+
}: WordRevealProps) {
|
|
96
|
+
const prefersReducedMotion = useReducedMotion()
|
|
97
|
+
const ref = React.useRef<HTMLDivElement>(null)
|
|
98
|
+
const isInView = useInView(ref, { once: true, amount: 0.5 })
|
|
99
|
+
|
|
100
|
+
const shouldAnimate = triggerOnView ? isInView : true
|
|
101
|
+
const words = text.split(" ")
|
|
102
|
+
|
|
103
|
+
if (prefersReducedMotion) {
|
|
104
|
+
return (
|
|
105
|
+
<div className={className} {...props}>
|
|
106
|
+
{text}
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div
|
|
113
|
+
ref={ref}
|
|
114
|
+
className={cn("inline-block", className)}
|
|
115
|
+
{...props}
|
|
116
|
+
>
|
|
117
|
+
{words.map((word, i) => (
|
|
118
|
+
<motion.span
|
|
119
|
+
key={`${word}-${i}`}
|
|
120
|
+
className="inline-block"
|
|
121
|
+
initial={{ opacity: 0, y: 20, filter: "blur(10px)" }}
|
|
122
|
+
animate={
|
|
123
|
+
shouldAnimate
|
|
124
|
+
? { opacity: 1, y: 0, filter: "blur(0px)" }
|
|
125
|
+
: { opacity: 0, y: 20, filter: "blur(10px)" }
|
|
126
|
+
}
|
|
127
|
+
transition={{
|
|
128
|
+
duration,
|
|
129
|
+
delay: i * delay,
|
|
130
|
+
ease: [0.2, 0.65, 0.3, 0.9],
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
{word}
|
|
134
|
+
{i < words.length - 1 && "\u00A0"}
|
|
135
|
+
</motion.span>
|
|
136
|
+
))}
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
}
|