Skip to content

Commit 45ae237

Browse files
authored
Merge pull request #8 from adriannoes/feat/design-system-s2-premium-components
feat(design): add GlassContainer, BentoGrid, BackgroundPaths, AnimatedText, EmptyState components
2 parents 9089917 + 459a9c2 commit 45ae237

File tree

12 files changed

+570
-19
lines changed

12 files changed

+570
-19
lines changed

src/app/globals.css

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,22 @@
142142
}
143143

144144
@layer utilities {
145+
/* Touch-safe hover lift */
146+
@media (hover: hover) {
147+
.hover-lift:hover {
148+
transform: translateY(-0.125rem);
149+
}
150+
}
151+
.hover-lift {
152+
will-change: transform;
153+
transition:
154+
transform 0.3s ease,
155+
box-shadow 0.3s ease;
156+
}
157+
.hover-lift:active {
158+
transform: scale(0.98);
159+
}
160+
145161
/* Smooth node dragging */
146162
.node-drag-active {
147163
transition: transform 0.1s cubic-bezier(0.4, 0, 0.2, 1);
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"use client"
2+
3+
import { motion } from "framer-motion"
4+
import { cn } from "@/lib/utils"
5+
6+
interface AnimatedTextProps {
7+
text: string
8+
className?: string
9+
as?: "h1" | "h2" | "h3" | "p" | "span"
10+
delay?: number
11+
/** When true, animation triggers when in viewport */
12+
triggerOnView?: boolean
13+
}
14+
15+
function AnimatedText({
16+
text,
17+
className,
18+
as: Tag = "h1",
19+
delay = 0,
20+
triggerOnView = false,
21+
}: AnimatedTextProps) {
22+
const words = text.split(/\s+/).filter(Boolean)
23+
24+
const animationProps = triggerOnView
25+
? {
26+
initial: { y: 20, opacity: 0 },
27+
whileInView: { y: 0, opacity: 1 },
28+
viewport: { once: true, margin: "-50px" } as const,
29+
}
30+
: {
31+
initial: { y: 20, opacity: 0 },
32+
animate: { y: 0, opacity: 1 },
33+
}
34+
35+
return (
36+
<Tag className={cn(className)}>
37+
{words.map((word, i) => (
38+
<motion.span
39+
key={`${word}-${i}`}
40+
className="mr-[0.25em] inline-block"
41+
{...animationProps}
42+
transition={{
43+
type: "spring",
44+
stiffness: 150,
45+
damping: 25,
46+
delay: delay + i * 0.05,
47+
}}
48+
>
49+
{word}
50+
</motion.span>
51+
))}
52+
</Tag>
53+
)
54+
}
55+
56+
export { AnimatedText }
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"use client"
2+
3+
import { motion } from "framer-motion"
4+
import { useMemo } from "react"
5+
import { cn } from "@/lib/utils"
6+
7+
interface BackgroundPathsProps {
8+
className?: string
9+
pathCount?: number
10+
}
11+
12+
function seededRandom(seed: number): (n: number) => number {
13+
return (n: number) => (Math.sin(seed * n) + 1) / 2
14+
}
15+
16+
function createPathData(index: number): string {
17+
const f = seededRandom(index * 7919 + 1)
18+
const y0 = 5 + f(1) * 90
19+
const y1 = 5 + f(2) * 90
20+
const c1x = 20 + f(3) * 60
21+
const c1y = 10 + f(4) * 80
22+
const c2x = 40 + f(5) * 50
23+
const c2y = 20 + f(6) * 60
24+
return `M 0 ${y0} C ${c1x} ${c1y} ${c2x} ${c2y} 100 ${y1}`
25+
}
26+
27+
function BackgroundPaths({ className, pathCount = 6 }: BackgroundPathsProps) {
28+
const paths = useMemo(() => {
29+
return Array.from({ length: pathCount }, (_, i) => ({
30+
d: createPathData(i),
31+
opacity: 0.03 + i * 0.015,
32+
duration: 20 + i * 5,
33+
}))
34+
}, [pathCount])
35+
36+
return (
37+
<div className={cn("text-foreground absolute inset-0 -z-10 overflow-hidden", className)}>
38+
<svg className="h-full w-full" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden>
39+
{paths.map(({ d, opacity, duration }, i) => (
40+
<motion.path
41+
key={i}
42+
d={d}
43+
fill="none"
44+
stroke="currentColor"
45+
strokeWidth={0.5}
46+
opacity={opacity}
47+
strokeDasharray="1000"
48+
initial={{ strokeDashoffset: 1000 }}
49+
animate={{ strokeDashoffset: 0 }}
50+
transition={{
51+
duration,
52+
repeat: Infinity,
53+
repeatType: "loop",
54+
ease: "linear",
55+
}}
56+
className={i >= 4 ? "max-md:hidden" : undefined}
57+
/>
58+
))}
59+
</svg>
60+
</div>
61+
)
62+
}
63+
64+
export { BackgroundPaths }

src/components/ui/bento-grid.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { forwardRef } from "react"
2+
import { type LucideIcon } from "lucide-react"
3+
import { cn } from "@/lib/utils"
4+
5+
interface BentoGridProps {
6+
children: React.ReactNode
7+
className?: string
8+
}
9+
10+
function BentoGrid({ children, className }: BentoGridProps) {
11+
return (
12+
<div
13+
data-slot="bento-grid"
14+
className={cn("grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3", className)}
15+
>
16+
{children}
17+
</div>
18+
)
19+
}
20+
21+
interface BentoCardProps {
22+
title: string
23+
description: string
24+
icon: LucideIcon
25+
value?: string | number
26+
className?: string
27+
}
28+
29+
const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>(
30+
({ title, description, icon: Icon, value, className }, ref) => (
31+
<div
32+
ref={ref}
33+
data-slot="bento-card"
34+
className={cn(
35+
"group border-border bg-card hover-lift relative overflow-hidden rounded-xl border p-6 transition-all duration-300 hover:shadow-sm",
36+
className,
37+
)}
38+
>
39+
{/* Radial grid reveal */}
40+
<div
41+
data-slot="bento-card-radial"
42+
aria-hidden
43+
className="absolute inset-0 opacity-0 transition-opacity duration-500 group-hover:opacity-100"
44+
style={{
45+
backgroundImage:
46+
"radial-gradient(circle, oklch(var(--foreground) / 0.04) 1px, transparent 1px)",
47+
backgroundSize: "4px 4px",
48+
}}
49+
/>
50+
{/* Gleam border overlay */}
51+
<div
52+
aria-hidden
53+
className="via-muted absolute inset-0 bg-gradient-to-br from-transparent to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100"
54+
/>
55+
<div className="relative">
56+
<div className="bg-muted mb-3 w-fit rounded-lg p-2">
57+
<Icon className="h-5 w-5" />
58+
</div>
59+
<h3 className="text-lg font-semibold">{title}</h3>
60+
<p className="text-muted-foreground mt-1 text-sm">{description}</p>
61+
{value != null && (
62+
<p className="mt-2 font-mono text-3xl font-bold tracking-tight">{value}</p>
63+
)}
64+
</div>
65+
</div>
66+
),
67+
)
68+
69+
BentoCard.displayName = "BentoCard"
70+
71+
export { BentoGrid, BentoCard }

src/components/ui/empty-state.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { type LucideIcon } from "lucide-react"
2+
import Link from "next/link"
3+
import {
4+
Empty,
5+
EmptyContent,
6+
EmptyDescription,
7+
EmptyHeader,
8+
EmptyMedia,
9+
EmptyTitle,
10+
} from "@/components/ui/empty"
11+
import { Button } from "@/components/ui/button"
12+
13+
interface EmptyStateProps {
14+
icon: LucideIcon
15+
title: string
16+
description: string
17+
actionLabel?: string
18+
actionHref?: string
19+
/** Requires client boundary. Use actionHref in Server Components. */
20+
onAction?: () => void
21+
}
22+
23+
function EmptyState({
24+
icon: Icon,
25+
title,
26+
description,
27+
actionLabel,
28+
actionHref,
29+
onAction,
30+
}: EmptyStateProps) {
31+
return (
32+
<Empty>
33+
<EmptyHeader>
34+
<EmptyMedia variant="icon">
35+
<Icon className="text-muted-foreground h-5 w-5 opacity-50" />
36+
</EmptyMedia>
37+
<EmptyTitle>
38+
<h2 className="text-lg font-semibold">{title}</h2>
39+
</EmptyTitle>
40+
<EmptyDescription>{description}</EmptyDescription>
41+
</EmptyHeader>
42+
{actionLabel && (
43+
<EmptyContent>
44+
{actionHref ? (
45+
<Button asChild>
46+
<Link href={actionHref}>{actionLabel}</Link>
47+
</Button>
48+
) : (
49+
<Button onClick={onAction}>{actionLabel}</Button>
50+
)}
51+
</EmptyContent>
52+
)}
53+
</Empty>
54+
)
55+
}
56+
57+
export { EmptyState }
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { cn } from "@/lib/utils"
2+
3+
interface GlassContainerProps {
4+
children: React.ReactNode
5+
className?: string
6+
innerClassName?: string
7+
}
8+
9+
function GlassContainer({ children, className, innerClassName }: GlassContainerProps) {
10+
return (
11+
<div
12+
data-slot="glass-container"
13+
className={cn(
14+
"rounded-2xl bg-gradient-to-b from-black/10 to-white/10 p-px backdrop-blur-lg dark:from-white/10 dark:to-white/5",
15+
className,
16+
)}
17+
>
18+
<div
19+
className={cn("rounded-2xl bg-white/95 backdrop-blur-md dark:bg-black/80", innerClassName)}
20+
>
21+
{children}
22+
</div>
23+
</div>
24+
)
25+
}
26+
27+
export { GlassContainer }

src/components/ui/use-mobile.tsx

Lines changed: 0 additions & 19 deletions
This file was deleted.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// @vitest-environment jsdom
2+
import { describe, it, expect } from "vitest"
3+
import { render, screen } from "@testing-library/react"
4+
import { AnimatedText } from "@/components/ui/animated-text"
5+
6+
describe("AnimatedText", () => {
7+
it("splits text into individual word spans", () => {
8+
const { container } = render(<AnimatedText text="Hello World" />)
9+
const spans = container.querySelectorAll("span")
10+
expect(spans.length).toBe(2)
11+
expect(spans[0]).toHaveTextContent("Hello")
12+
expect(spans[1]).toHaveTextContent("World")
13+
})
14+
15+
it("renders as h1 by default", () => {
16+
const { container } = render(<AnimatedText text="Title" />)
17+
const h1 = container.querySelector("h1")
18+
expect(h1).toBeInTheDocument()
19+
expect(h1).toHaveTextContent("Title")
20+
})
21+
22+
it("renders as specified tag via as prop", () => {
23+
const { container } = render(<AnimatedText text="Subtitle" as="h2" />)
24+
const h2 = container.querySelector("h2")
25+
expect(h2).toBeInTheDocument()
26+
expect(h2).toHaveTextContent("Subtitle")
27+
})
28+
29+
it("applies className to outer tag", () => {
30+
const { container } = render(<AnimatedText text="Styled" className="text-lg font-bold" />)
31+
const h1 = container.querySelector("h1")
32+
expect(h1).toHaveClass("text-lg")
33+
expect(h1).toHaveClass("font-bold")
34+
})
35+
36+
it("filters empty words from multiple spaces", () => {
37+
const { container } = render(<AnimatedText text=" A B " />)
38+
const spans = container.querySelectorAll("span")
39+
expect(spans.length).toBe(2)
40+
expect(spans[0]).toHaveTextContent("A")
41+
expect(spans[1]).toHaveTextContent("B")
42+
})
43+
})

0 commit comments

Comments
 (0)