feat: enhance dashboard components with framer-motion animations
- Added motion effects to Donut, Gauge, Header, NoData, Sparkline components for improved user experience. - Implemented initial and animate properties for SVG elements to create smooth transitions. - Introduced new motion primitives for reusable animations across components. - Updated UI elements like badges and buttons with hover effects and transitions. - Enhanced skeleton and spinner components for better loading states. - Created a motion library for consistent animation variants and utility constants.
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { NoData } from "./no-data";
|
||||
|
||||
export function Donut({
|
||||
@@ -21,19 +24,24 @@ export function Donut({
|
||||
|
||||
let off = 0;
|
||||
return (
|
||||
<svg
|
||||
<motion.svg
|
||||
width={200}
|
||||
height={210}
|
||||
viewBox="0 0 200 210"
|
||||
role="img"
|
||||
aria-label="Service health donut chart"
|
||||
initial={{ opacity: 0, scale: 0.85 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, ease: [0.25, 0.1, 0, 1] }}
|
||||
>
|
||||
{segs.map((s) => {
|
||||
if (!s.n) return null;
|
||||
const frac = s.n / total;
|
||||
const ln = frac * circ;
|
||||
const el = (
|
||||
<circle
|
||||
const dashOffset = -off;
|
||||
off += ln;
|
||||
return (
|
||||
<motion.circle
|
||||
key={s.l}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
@@ -42,24 +50,31 @@ export function Donut({
|
||||
stroke={s.c}
|
||||
strokeWidth={14}
|
||||
strokeDasharray={`${ln} ${circ - ln}`}
|
||||
strokeDashoffset={-off}
|
||||
strokeDashoffset={dashOffset}
|
||||
transform={`rotate(-90 ${cx} ${cy})`}
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 1, delay: 0.2, ease: [0.25, 0.1, 0, 1] }}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
off += ln;
|
||||
return el;
|
||||
})}
|
||||
<text
|
||||
{/* Center total number */}
|
||||
<motion.text
|
||||
x={cx}
|
||||
y={cy - 4}
|
||||
textAnchor="middle"
|
||||
fill="#e6edf3"
|
||||
fill="currentColor"
|
||||
className="fill-foreground"
|
||||
fontSize={26}
|
||||
fontWeight={700}
|
||||
fontFamily="system-ui,sans-serif"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.6 }}
|
||||
>
|
||||
{total}
|
||||
</text>
|
||||
</motion.text>
|
||||
<text
|
||||
x={cx}
|
||||
y={cy + 14}
|
||||
@@ -68,13 +83,30 @@ export function Donut({
|
||||
fontSize={10}
|
||||
fontFamily="system-ui,sans-serif"
|
||||
>
|
||||
total
|
||||
services
|
||||
</text>
|
||||
{segs
|
||||
.filter((s) => s.n)
|
||||
.map((s, i) => (
|
||||
<g key={s.l}>
|
||||
<circle cx={16} cy={165 + i * 16} r={4} fill={s.c} />
|
||||
<motion.g
|
||||
key={s.l}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.8 + i * 0.15 }}
|
||||
>
|
||||
<motion.circle
|
||||
cx={16}
|
||||
cy={165 + i * 16}
|
||||
r={4}
|
||||
fill={s.c}
|
||||
animate={{ scale: [1, 1.2, 1] }}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration: 2,
|
||||
delay: i * 0.5,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
<text
|
||||
x={26}
|
||||
y={168 + i * 16}
|
||||
@@ -84,8 +116,8 @@ export function Donut({
|
||||
>
|
||||
{s.l}: {s.n}
|
||||
</text>
|
||||
</g>
|
||||
</motion.g>
|
||||
))}
|
||||
</svg>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { NoData } from "./no-data";
|
||||
|
||||
const W = 220;
|
||||
@@ -22,16 +25,22 @@ export function Gauge({
|
||||
const fw = BW * Math.min(pct / 100, 1);
|
||||
|
||||
return (
|
||||
<svg
|
||||
<motion.svg
|
||||
width={W}
|
||||
height={H}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
role="img"
|
||||
aria-label={`${label}: ${pct.toFixed(1)}${unit}`}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.25, 0.1, 0, 1] }}
|
||||
>
|
||||
{/* Track */}
|
||||
<rect x={BX} y={BY} width={BW} height={BH} rx={7} ry={7} fill="#1c2333" />
|
||||
|
||||
{/* Filled bar */}
|
||||
{fw > 0 && (
|
||||
<rect
|
||||
<motion.rect
|
||||
x={BX}
|
||||
y={BY}
|
||||
width={fw}
|
||||
@@ -40,9 +49,14 @@ export function Gauge({
|
||||
ry={7}
|
||||
fill={color}
|
||||
opacity={0.85}
|
||||
initial={{ scaleX: 0, transformOrigin: "left" }}
|
||||
animate={{ scaleX: 1, transformOrigin: "left" }}
|
||||
transition={{ duration: 0.8, delay: 0.15, ease: [0.25, 0.1, 0, 1] }}
|
||||
/>
|
||||
)}
|
||||
<text
|
||||
|
||||
{/* Label */}
|
||||
<motion.text
|
||||
x={W / 2}
|
||||
y={14}
|
||||
textAnchor="middle"
|
||||
@@ -50,10 +64,15 @@ export function Gauge({
|
||||
fontSize={11}
|
||||
fontWeight={600}
|
||||
fill={color}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.3 }}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
<text
|
||||
</motion.text>
|
||||
|
||||
{/* Value */}
|
||||
<motion.text
|
||||
x={W / 2}
|
||||
y={BY + BH + 24}
|
||||
textAnchor="middle"
|
||||
@@ -61,10 +80,13 @@ export function Gauge({
|
||||
fontSize={14}
|
||||
fontWeight={700}
|
||||
fill="#e6edf3"
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.5 }}
|
||||
>
|
||||
{pct.toFixed(1)}
|
||||
{unit}
|
||||
</text>
|
||||
</svg>
|
||||
</motion.text>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { DashboardData } from "@/lib/dashboard/types";
|
||||
import { springGentle } from "@/lib/motion";
|
||||
|
||||
export function DashboardHeader() {
|
||||
const [degraded, setDegraded] = useState(0);
|
||||
@@ -46,49 +48,96 @@ export function DashboardHeader() {
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const isHealthy = total > 0 && degraded === 0;
|
||||
const hasIssues = degraded > 0;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 flex items-center justify-between gap-2 border-b bg-background/90 px-5 py-3 backdrop-blur-xl">
|
||||
<a href="/" className="flex items-center gap-2.5">
|
||||
<motion.header
|
||||
initial={{ y: -20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4, ease: [0.25, 0.1, 0, 1] }}
|
||||
className="sticky top-0 z-50 flex items-center justify-between gap-2 border-b bg-background/80 px-5 py-3 backdrop-blur-2xl supports-backdrop-filter:bg-background/60"
|
||||
>
|
||||
<motion.a
|
||||
href="/"
|
||||
className="flex items-center gap-2.5"
|
||||
whileHover={{ x: 2 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-foreground text-xs font-bold text-background">
|
||||
AH
|
||||
</span>
|
||||
<h1 className="text-base font-semibold">Asep Haryana</h1>
|
||||
</a>
|
||||
</motion.a>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`h-auto gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${
|
||||
degraded > 0
|
||||
? "border-amber-500/30 bg-amber-900/20 text-amber-500"
|
||||
: total > 0
|
||||
? "border-emerald-500/30 bg-emerald-900/20 text-emerald-400"
|
||||
: ""
|
||||
}`}
|
||||
<motion.div
|
||||
animate={
|
||||
hasIssues
|
||||
? {
|
||||
scale: [1, 1.02, 1],
|
||||
transition: { repeat: Number.POSITIVE_INFINITY, duration: 3 },
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{total > 0 && (
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${
|
||||
degraded > 0 ? "bg-amber-500" : "bg-emerald-400"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{total === 0
|
||||
? "No services"
|
||||
: degraded > 0
|
||||
? `${degraded} degraded`
|
||||
: "All Systems Operational"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
aria-label="Toggle theme"
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`h-auto gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${
|
||||
hasIssues
|
||||
? "border-amber-500/30 bg-amber-900/20 text-amber-500"
|
||||
: total > 0
|
||||
? "border-emerald-500/30 bg-emerald-900/20 text-emerald-400"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{total > 0 && (
|
||||
<motion.span
|
||||
animate={{
|
||||
scale: [1, 1.3, 1],
|
||||
opacity: [0.7, 1, 0.7],
|
||||
}}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration: isHealthy ? 2.5 : 1.5,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className={`h-1.5 w-1.5 rounded-full ${
|
||||
hasIssues ? "bg-amber-500" : "bg-emerald-400"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{total === 0
|
||||
? "No services"
|
||||
: hasIssues
|
||||
? `${degraded} degraded`
|
||||
: "All Systems Operational"}
|
||||
</Badge>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
whileHover={{ rotate: 30 }}
|
||||
whileTap={{ rotate: 60, scale: 0.9 }}
|
||||
transition={springGentle}
|
||||
>
|
||||
<Sun className="hidden dark:block!" />
|
||||
<Moon className="block dark:hidden!" />
|
||||
</Button>
|
||||
<span className="text-[10px] text-muted-foreground">{time}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
<Sun className="hidden dark:block!" />
|
||||
<Moon className="block dark:hidden!" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
<motion.span
|
||||
key={time}
|
||||
initial={{ opacity: 0.6, y: -2 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="text-[10px] tabular-nums text-muted-foreground"
|
||||
>
|
||||
{time}
|
||||
</motion.span>
|
||||
</div>
|
||||
</header>
|
||||
</motion.header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export function NoData() {
|
||||
return (
|
||||
<svg
|
||||
<motion.svg
|
||||
width={200}
|
||||
height={100}
|
||||
viewBox="0 0 200 100"
|
||||
role="img"
|
||||
aria-label="No data available"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<text
|
||||
<motion.text
|
||||
x={100}
|
||||
y={50}
|
||||
textAnchor="middle"
|
||||
fill="#6e7681"
|
||||
fontSize={12}
|
||||
fontFamily="system-ui,sans-serif"
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
No data
|
||||
</text>
|
||||
</svg>
|
||||
</motion.text>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { NoData } from "./no-data";
|
||||
|
||||
const W = 300;
|
||||
@@ -73,25 +76,59 @@ export function Sparkline({ data, color }: { data: number[]; color: string }) {
|
||||
const area = `M${PL},${PT + VH} L${pts} L${PL + VW},${PT + VH} Z`;
|
||||
const lv = data[data.length - 1];
|
||||
const ly = PT + VH * (1 - lv / maxV);
|
||||
const d = `M${pts}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
<motion.svg
|
||||
width={W}
|
||||
height={H}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
role="img"
|
||||
aria-label="Sparkline chart"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
{gridLines(maxV)}
|
||||
<path d={area} fill={color} opacity={0.15} />
|
||||
<polyline
|
||||
points={pts}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
|
||||
{/* Area fill */}
|
||||
<motion.path
|
||||
d={area}
|
||||
fill={color}
|
||||
opacity={0.15}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.15 }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
/>
|
||||
<text
|
||||
|
||||
{/* Animated line path */}
|
||||
{data.length > 1 && (
|
||||
<motion.path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 1.2, ease: [0.25, 0.1, 0, 1] }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Endpoint dot */}
|
||||
<motion.circle
|
||||
cx={PL + VW}
|
||||
cy={ly}
|
||||
r={3}
|
||||
fill={color}
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: 1.0 }}
|
||||
/>
|
||||
|
||||
{/* Endpoint value label */}
|
||||
<motion.text
|
||||
x={PL + VW}
|
||||
y={ly - 10}
|
||||
textAnchor="end"
|
||||
@@ -99,10 +136,14 @@ export function Sparkline({ data, color }: { data: number[]; color: string }) {
|
||||
fontSize={11}
|
||||
fontWeight={600}
|
||||
fontFamily="system-ui,sans-serif"
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 1.1 }}
|
||||
>
|
||||
{lv.toFixed(1)}
|
||||
</text>
|
||||
</motion.text>
|
||||
|
||||
{xAxisLabels(data.length)}
|
||||
</svg>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all duration-300 ease-out focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -8,7 +8,8 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/80 shadow-xs hover:shadow-sm hover:shadow-primary/20",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
|
||||
@@ -12,7 +12,7 @@ function Card({
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl border border-border bg-card py-(--card-spacing) text-sm text-card-foreground [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl border border-border bg-card py-(--card-spacing) text-sm text-card-foreground shadow-xs transition-all duration-300 ease-out hover:shadow-md hover:shadow-border/30 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import { type HTMLMotionProps, motion, type Variants } from "framer-motion";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { fadeInUp, scaleInSpring, stagger } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ─── Viewport-based reveal wrapper ───────────────────────────
|
||||
|
||||
type RevealProps = HTMLMotionProps<"div"> & {
|
||||
variants?: Variants;
|
||||
once?: boolean;
|
||||
amount?: number | "some" | "all";
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps children in a fade-in-up animation triggered when they enter the viewport.
|
||||
* Use `asChild` composition or nest directly — children receive no special bindings.
|
||||
*/
|
||||
export function Reveal({
|
||||
children,
|
||||
className,
|
||||
variants = fadeInUp,
|
||||
once = true,
|
||||
amount = 0.2,
|
||||
...props
|
||||
}: RevealProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once, amount }}
|
||||
variants={variants}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stagger container ───────────────────────────────────────
|
||||
|
||||
type StaggerContainerProps = HTMLMotionProps<"div"> & {
|
||||
staggerVariants?: Variants;
|
||||
once?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parent container that staggers child `motion` elements.
|
||||
* Children should have matching `variants` (e.g. `fadeInUp`).
|
||||
*/
|
||||
export function StaggerContainer({
|
||||
children,
|
||||
className,
|
||||
staggerVariants = stagger,
|
||||
once = true,
|
||||
...props
|
||||
}: StaggerContainerProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once, amount: 0.1 }}
|
||||
variants={staggerVariants}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Single-item child with fadeInUp ─────────────────────────
|
||||
|
||||
type MotionItemProps = HTMLMotionProps<"div"> & { index?: number };
|
||||
|
||||
/**
|
||||
* A single motion item meant to be used as a child of StaggerContainer.
|
||||
* Wraps content in a `motion.div` with fadeInUp variants.
|
||||
*/
|
||||
export function MotionItem({ children, className, ...props }: MotionItemProps) {
|
||||
return (
|
||||
<motion.div variants={fadeInUp} className={className} {...props}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Scale reveal (for cards, dialogs) ───────────────────────
|
||||
|
||||
export function ScaleReveal({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLMotionProps<"div">) {
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.15 }}
|
||||
variants={scaleInSpring}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Animated presence wrapper ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Fades + scales content in/out for mount/unmount transitions.
|
||||
*/
|
||||
export function AnimatedPresence({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.25, ease: [0.25, 0.1, 0, 1] as const }}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Animated number counter ─────────────────────────────────
|
||||
|
||||
type AnimatedNumberProps = {
|
||||
value: number;
|
||||
decimals?: number;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Animates from 0 to `value` on mount and when `value` changes.
|
||||
*/
|
||||
export function AnimatedNumber({
|
||||
value,
|
||||
decimals = 0,
|
||||
duration: dur = 0.6,
|
||||
className,
|
||||
}: AnimatedNumberProps) {
|
||||
return (
|
||||
<motion.span
|
||||
key={value}
|
||||
initial={{ opacity: 0, y: 12, filter: "blur(4px)" }}
|
||||
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||
transition={{ duration: dur, ease: [0.25, 0.1, 0, 1] as const }}
|
||||
className={cn("inline-block tabular-nums", className)}
|
||||
>
|
||||
{value.toFixed(decimals)}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Animated text reveal (letter by letter) ─────────────────
|
||||
|
||||
type TypewriterProps = {
|
||||
text: string;
|
||||
className?: string;
|
||||
speed?: number;
|
||||
/** Delay in ms before the first character appears */
|
||||
startDelay?: number;
|
||||
/** When true, shows a blinking cursor block while typing */
|
||||
cursor?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reveals text one character at a time using progressive `setTimeout`.
|
||||
* Characters are appended to a single text node so CSS gradient
|
||||
* (`background-clip: text`) works seamlessly across the full string.
|
||||
*/
|
||||
export function Typewriter({
|
||||
text,
|
||||
className,
|
||||
speed = 0.03,
|
||||
startDelay = 250,
|
||||
cursor: showCursor = true,
|
||||
}: TypewriterProps) {
|
||||
const [displayed, setDisplayed] = useState("");
|
||||
const done = displayed.length >= text.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (done) return;
|
||||
const delay =
|
||||
displayed.length === 0 ? startDelay : Math.round(speed * 1000);
|
||||
const timer = setTimeout(
|
||||
() => setDisplayed(text.slice(0, displayed.length + 1)),
|
||||
delay,
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}, [displayed, text, speed, startDelay, done]);
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{displayed}
|
||||
{showCursor && !done && (
|
||||
<span className="inline-block size-2.5 bg-primary ml-0.5 rounded-none align-text-bottom animate-pulse" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Blur-in reveal ──────────────────────────────────────────
|
||||
|
||||
export function BlurReveal({
|
||||
children,
|
||||
className,
|
||||
delay = 0,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, filter: "blur(8px)" }}
|
||||
whileInView={{ opacity: 1, filter: "blur(0px)" }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay, ease: [0.25, 0.1, 0, 1] as const }}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Interactive card wrapper ────────────────────────────────
|
||||
|
||||
type InteractiveCardProps = HTMLMotionProps<"div"> & {
|
||||
hoverScale?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Card wrapper with subtle hover lift and glow.
|
||||
*/
|
||||
export function InteractiveCard({
|
||||
children,
|
||||
className,
|
||||
hoverScale = 1.01,
|
||||
...props
|
||||
}: InteractiveCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
whileHover={{
|
||||
y: -3,
|
||||
scale: hoverScale,
|
||||
transition: { duration: 0.2, ease: "easeOut" },
|
||||
}}
|
||||
whileTap={{ y: 0, scale: 0.99 }}
|
||||
className={cn("cursor-default transition-shadow duration-300", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,10 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
className={cn(
|
||||
"animate-pulse rounded-md bg-muted/60 bg-gradient-to-r from-muted/60 via-muted/80 to-muted/60 bg-[length:200%_100%] animate-shimmer",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -6,7 +8,7 @@ function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
<Loader2Icon
|
||||
data-slot="spinner"
|
||||
aria-label="Loading"
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
className={cn("size-4 animate-spin text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user