Files
hub/src/components/ui/motion-primitives.tsx
T
asepharyana 22cdc2e84b 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.
2026-07-23 17:55:57 +07:00

271 lines
6.8 KiB
TypeScript

"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>
);
}