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:
asepharyana
2026-07-23 17:55:57 +07:00
parent e528b6a726
commit 22cdc2e84b
17 changed files with 1500 additions and 486 deletions
+46 -14
View File
@@ -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>
);
}
+29 -7
View File
@@ -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>
);
}
+84 -35
View File
@@ -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>
);
}
+14 -4
View File
@@ -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>
);
}
+52 -11
View File
@@ -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>
);
}
+1 -1
View File
@@ -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: {
+2 -1
View File
@@ -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:
+1 -1
View File
@@ -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}
+270
View File
@@ -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 -1
View File
@@ -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}
/>
);
+3 -1
View File
@@ -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}
/>
);