diff --git a/package.json b/package.json
index c334af7b..6b763a1a 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"croner": "^10.0.1",
+ "framer-motion": "^12.42.2",
"hash-wasm": "^4.12.0",
"ioredis": "^5.11.1",
"jpeg-js": "^0.4.4",
@@ -55,6 +56,7 @@
"sanitize-html": "^2.17.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
+ "tailwindcss-animate": "^1.0.7",
"zod": "^3.24.0"
},
"devDependencies": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 24e37cef..5a09a2cd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -49,6 +49,9 @@ importers:
croner:
specifier: ^10.0.1
version: 10.0.1
+ framer-motion:
+ specifier: ^12.42.2
+ version: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
hash-wasm:
specifier: ^4.12.0
version: 4.12.0
@@ -115,6 +118,9 @@ importers:
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
+ tailwindcss-animate:
+ specifier: ^1.0.7
+ version: 1.0.7(tailwindcss@4.3.2)
zod:
specifier: ^3.24.0
version: 3.25.76
@@ -2462,6 +2468,20 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
+ framer-motion@12.42.2:
+ resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==}
+ peerDependencies:
+ '@emotion/is-prop-valid': '*'
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/is-prop-valid':
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2693,6 +2713,12 @@ packages:
resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
hasBin: true
+ motion-dom@12.42.2:
+ resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==}
+
+ motion-utils@12.39.0:
+ resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -3085,6 +3111,11 @@ packages:
tailwind-merge@3.6.0:
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
+ tailwindcss-animate@1.0.7:
+ resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
+ peerDependencies:
+ tailwindcss: '>=3.0.0 || insiders'
+
tailwindcss@4.3.2:
resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==}
@@ -5252,6 +5283,15 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
+ framer-motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ motion-dom: 12.42.2
+ motion-utils: 12.39.0
+ tslib: 2.8.1
+ optionalDependencies:
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
fsevents@2.3.3:
optional: true
@@ -5445,6 +5485,12 @@ snapshots:
mini-svg-data-uri@1.4.4: {}
+ motion-dom@12.42.2:
+ dependencies:
+ motion-utils: 12.39.0
+
+ motion-utils@12.39.0: {}
+
ms@2.1.3: {}
music-metadata@11.13.0:
@@ -5911,6 +5957,10 @@ snapshots:
tailwind-merge@3.6.0: {}
+ tailwindcss-animate@1.0.7(tailwindcss@4.3.2):
+ dependencies:
+ tailwindcss: 4.3.2
+
tailwindcss@4.3.2: {}
tapable@2.3.3: {}
diff --git a/src/app/(site)/layout.tsx b/src/app/(site)/layout.tsx
index 6eee060e..d341a419 100644
--- a/src/app/(site)/layout.tsx
+++ b/src/app/(site)/layout.tsx
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
+import MotionPageWrapper from "@/components/motion-page-wrapper";
import { Navigation } from "@/components/navigation";
import RadioPlayerGate from "@/components/public/radio-player-gate";
import { SiteFooter } from "@/components/site-footer";
@@ -17,7 +18,9 @@ export default function SiteLayout({ children }: { children: ReactNode }) {
-
{children}
+
+ {children}
+
diff --git a/src/app/globals.css b/src/app/globals.css
index 5a8cd151..fe19cd9c 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,6 +1,7 @@
@import "tailwindcss";
@plugin "@tailwindcss/forms";
@plugin "@tailwindcss/typography";
+@plugin "tailwindcss-animate";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
@@ -270,7 +271,6 @@ html.dark body.site-bg:has([data-admin]) {
background-blend-mode: normal;
}
-
.article-body {
line-height: 1.7;
}
@@ -382,7 +382,11 @@ a:hover {
font-size: 14px;
cursor: pointer;
transition: all 0.18s ease;
- background: color-mix(in srgb, var(--color-text-muted) 14%, var(--color-surface));
+ background: color-mix(
+ in srgb,
+ var(--color-text-muted) 14%,
+ var(--color-surface)
+ );
color: var(--color-text-readable, var(--color-text));
text-decoration: none;
}
@@ -642,8 +646,16 @@ tbody tr:hover {
flex-direction: column;
width: 100%;
background: var(--color-surface);
- color: var(--color-surface-text-readable, var(--color-text-readable, var(--color-text)));
- border: 1px solid color-mix(in srgb, var(--color-text-muted-readable, var(--color-text-muted)) 12%, transparent);
+ color: var(
+ --color-surface-text-readable,
+ var(--color-text-readable, var(--color-text))
+ );
+ border: 1px solid
+ color-mix(
+ in srgb,
+ var(--color-text-muted-readable, var(--color-text-muted)) 12%,
+ transparent
+ );
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
@@ -663,7 +675,11 @@ tbody tr:hover {
color-mix(in srgb, var(--color-primary) 5%, var(--color-surface))
);
border-bottom: 1px solid
- color-mix(in srgb, var(--color-text-muted-readable, var(--color-text-muted)) 8%, transparent);
+ color-mix(
+ in srgb,
+ var(--color-text-muted-readable, var(--color-text-muted)) 8%,
+ transparent
+ );
}
.content-card-icon {
flex: none;
@@ -690,7 +706,10 @@ tbody tr:hover {
font-weight: 700;
font-size: 1rem;
line-height: 1.3;
- color: var(--color-surface-text-readable, var(--color-text-readable, var(--color-text)));
+ color: var(
+ --color-surface-text-readable,
+ var(--color-text-readable, var(--color-text))
+ );
}
.content-card-subtitle {
margin: 0.15rem 0 0;
@@ -740,7 +759,12 @@ tbody tr:hover {
}
.stat-block {
background: var(--color-background);
- border: 1px solid color-mix(in srgb, var(--color-text-muted-readable, var(--color-text-muted)) 12%, transparent);
+ border: 1px solid
+ color-mix(
+ in srgb,
+ var(--color-text-muted-readable, var(--color-text-muted)) 12%,
+ transparent
+ );
border-radius: 12px;
padding: 0.85rem 1rem;
text-align: center;
@@ -776,7 +800,11 @@ tbody tr:hover {
font-weight: 700;
padding: 0.1rem 0.55rem 0.1rem 0.45rem;
border-radius: 999px;
- background: color-mix(in srgb, var(--color-text-muted-readable, var(--color-text-muted)) 14%, transparent);
+ background: color-mix(
+ in srgb,
+ var(--color-text-muted-readable, var(--color-text-muted)) 14%,
+ transparent
+ );
color: var(--color-text-muted-readable, var(--color-text-muted));
}
.online-badge.online {
@@ -806,7 +834,12 @@ tbody tr:hover {
font-weight: 800;
font-size: 0.95rem;
background: var(--color-background);
- border: 1px solid color-mix(in srgb, var(--color-text-muted-readable, var(--color-text-muted)) 18%, transparent);
+ border: 1px solid
+ color-mix(
+ in srgb,
+ var(--color-text-muted-readable, var(--color-text-muted)) 18%,
+ transparent
+ );
}
.rank-badge.rank-1 {
background: color-mix(in srgb, var(--color-warning) 20%, transparent);
@@ -852,7 +885,11 @@ tbody tr:hover {
gap: 0.25rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid
- color-mix(in srgb, var(--color-text-muted-readable, var(--color-text-muted)) 14%, transparent);
+ color-mix(
+ in srgb,
+ var(--color-text-muted-readable, var(--color-text-muted)) 14%,
+ transparent
+ );
overflow-x: auto;
}
.radio-sidebar-head {
@@ -917,7 +954,11 @@ tbody tr:hover {
position: relative;
width: min(440px, 100%);
border: 1px solid
- color-mix(in srgb, var(--color-primary) 30%, color-mix(in srgb, var(--color-text-muted) 22%, transparent));
+ color-mix(
+ in srgb,
+ var(--color-primary) 30%,
+ color-mix(in srgb, var(--color-text-muted) 22%, transparent)
+ );
border-radius: 8px;
background:
linear-gradient(
@@ -927,7 +968,10 @@ tbody tr:hover {
),
var(--color-surface);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.34);
- color: var(--color-surface-text-readable, var(--color-text-readable, var(--color-text)));
+ color: var(
+ --color-surface-text-readable,
+ var(--color-text-readable, var(--color-text))
+ );
padding: 1.25rem;
}
.cms-info-close {
@@ -938,7 +982,12 @@ tbody tr:hover {
width: 2rem;
height: 2rem;
place-items: center;
- border: 1px solid color-mix(in srgb, var(--color-text-muted-readable, var(--color-text-muted)) 18%, transparent);
+ border: 1px solid
+ color-mix(
+ in srgb,
+ var(--color-text-muted-readable, var(--color-text-muted)) 18%,
+ transparent
+ );
border-radius: 999px;
background: color-mix(in srgb, var(--color-surface) 84%, transparent);
color: var(--color-text-muted-readable, var(--color-text-muted));
@@ -947,7 +996,10 @@ tbody tr:hover {
line-height: 1;
}
.cms-info-close:hover {
- color: var(--color-surface-text-readable, var(--color-text-readable, var(--color-text)));
+ color: var(
+ --color-surface-text-readable,
+ var(--color-text-readable, var(--color-text))
+ );
border-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
}
.cms-info-kicker {
@@ -970,7 +1022,10 @@ tbody tr:hover {
border: 1px solid color-mix(in srgb, var(--color-primary) 34%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
- color: var(--color-surface-text-readable, var(--color-text-readable, var(--color-text)));
+ color: var(
+ --color-surface-text-readable,
+ var(--color-text-readable, var(--color-text))
+ );
font-size: 0.75rem;
font-weight: 900;
padding: 0.3rem 0.6rem;
@@ -988,7 +1043,12 @@ tbody tr:hover {
margin: 1rem 0 0;
}
.cms-info-grid div {
- border: 1px solid color-mix(in srgb, var(--color-text-muted-readable, var(--color-text-muted)) 14%, transparent);
+ border: 1px solid
+ color-mix(
+ in srgb,
+ var(--color-text-muted-readable, var(--color-text-muted)) 14%,
+ transparent
+ );
border-radius: 8px;
background: color-mix(in srgb, var(--color-background) 68%, transparent);
padding: 0.7rem;
@@ -1001,7 +1061,10 @@ tbody tr:hover {
}
.cms-info-grid dd {
margin: 0.18rem 0 0;
- color: var(--color-surface-text-readable, var(--color-text-readable, var(--color-text)));
+ color: var(
+ --color-surface-text-readable,
+ var(--color-text-readable, var(--color-text))
+ );
font-size: 0.8rem;
font-weight: 900;
line-height: 1.25;
@@ -1022,7 +1085,10 @@ tbody tr:hover {
padding: 0.65rem 0.75rem;
}
.cms-info-credit span {
- color: var(--color-surface-text-readable, var(--color-text-readable, var(--color-text)));
+ color: var(
+ --color-surface-text-readable,
+ var(--color-text-readable, var(--color-text))
+ );
font-weight: 900;
}
.cms-info-credit small {
@@ -1174,9 +1240,10 @@ body:has([data-admin]) {
}
/* Admin forms / boxes follow admin tokens in both light and dark. */
-body:has([data-admin]) input:not([type="checkbox"]):not([type="radio"]):not(
- [type="range"]
- ):not([type="file"]):not([type="color"]),
+body:has([data-admin])
+input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not(
+ [type="file"]
+):not([type="color"]),
body:has([data-admin]) select,
body:has([data-admin]) textarea {
background-color: var(--admin-surface-elevated);
@@ -1210,9 +1277,10 @@ body:has([data-admin]) .btn-outline:hover {
color: var(--admin-text);
}
/* Override public html.dark input hardcodes inside admin. */
-html.dark body:has([data-admin]) input:not([type="checkbox"]):not(
- [type="radio"]
- ):not([type="range"]):not([type="file"]):not([type="color"]),
+html.dark body:has([data-admin])
+ input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not(
+ [type="file"]
+ ):not([type="color"]),
html.dark body:has([data-admin]) select,
html.dark body:has([data-admin]) textarea {
background: var(--admin-surface-elevated);
diff --git a/src/components/home/guest-view.tsx b/src/components/home/guest-view.tsx
index e65b9082..cf82c6e4 100644
--- a/src/components/home/guest-view.tsx
+++ b/src/components/home/guest-view.tsx
@@ -2,6 +2,7 @@ import Image from "next/image";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { HomeLoginForm } from "@/components/auth/home-login-form";
+import { Reveal } from "@/components/motion-reveal";
import { avatarImageUrl, excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
@@ -40,7 +41,7 @@ export default async function GuestView() {
return (
);
}
diff --git a/src/components/home/user-view.tsx b/src/components/home/user-view.tsx
index 1e85dd0c..3ea575c6 100644
--- a/src/components/home/user-view.tsx
+++ b/src/components/home/user-view.tsx
@@ -1,6 +1,7 @@
import Image from "next/image";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
+import { Reveal } from "@/components/motion-reveal";
import { avatarImageUrl, excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
@@ -54,7 +55,7 @@ export default async function UserView({
return (
-
+
-
@@ -91,16 +94,19 @@ export default async function UserView({
href={`/u/${username}`}
className="transition-transform duration-300 hover:scale-105"
>
-
+
-
@@ -123,8 +131,12 @@ export default async function UserView({
>
{username}
-
{motto || "Welcome back!"}
@@ -217,9 +229,9 @@ export default async function UserView({
)}
-
+
-
+
{latestArticle ? (
) : null}
-
+
);
}
diff --git a/src/components/mobile-nav.tsx b/src/components/mobile-nav.tsx
index 3200005e..d1a55c20 100644
--- a/src/components/mobile-nav.tsx
+++ b/src/components/mobile-nav.tsx
@@ -1,7 +1,9 @@
"use client";
+import { AnimatePresence, motion } from "framer-motion";
import Image from "next/image";
-import { type ReactNode, useRef } from "react";
+import { type ReactNode, useRef, useState } from "react";
+import { mobileMenuVariants } from "@/lib/motion";
interface MobileNavProps {
children: ReactNode;
@@ -16,30 +18,26 @@ export function MobileNav({
closeLabel = "Close menu",
brandLabel = "Atom",
}: MobileNavProps) {
- const detailsRef = useRef(null);
-
- const handleClick = (e: React.MouseEvent) => {
- if ((e.target as HTMLElement).closest("a") && detailsRef.current) {
- detailsRef.current.open = false;
- }
- };
+ const [open, setOpen] = useState(false);
+ const detailsRef = useRef(null);
return (
-
-
+
+ {open ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+
-
-
-
+ {open && (
+
+
-
-
- {brandLabel}
-
-
- {closeLabel}
-
-
+
+
+
+ {brandLabel}
+
+
+ {closeLabel}
+
+
- {children}
-
-
-
+ {children}
+
+
+ )}
+
+
);
}
diff --git a/src/components/motion-elements.tsx b/src/components/motion-elements.tsx
new file mode 100644
index 00000000..6f3ce819
--- /dev/null
+++ b/src/components/motion-elements.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import type { HTMLMotionProps } from "framer-motion";
+import { motion } from "framer-motion";
+import {
+ fadeIn,
+ fadeInUp,
+ hoverLift,
+ hoverScale,
+ scaleIn,
+ staggerContainer,
+ staggerItem,
+} from "@/lib/motion";
+
+type DivProps = HTMLMotionProps<"div">;
+
+export function AnimatedSection({ children, className, ...props }: DivProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function AnimatedChild({ children, className, ...props }: DivProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function FadeIn({ children, className, ...props }: DivProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function FadeInUp({ children, className, ...props }: DivProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function ScaleIn({ children, className, ...props }: DivProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function HoverCard({ children, className, ...props }: DivProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function HoverButton({ children, className, ...props }: DivProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/motion-page-wrapper.tsx b/src/components/motion-page-wrapper.tsx
new file mode 100644
index 00000000..f178ca11
--- /dev/null
+++ b/src/components/motion-page-wrapper.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { AnimatePresence, motion } from "framer-motion";
+import { usePathname } from "next/navigation";
+import type { ReactNode } from "react";
+import { pageTransition } from "@/lib/motion";
+
+export default function MotionPageWrapper({
+ children,
+}: {
+ children: ReactNode;
+}) {
+ const pathname = usePathname();
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/components/motion-reveal.tsx b/src/components/motion-reveal.tsx
new file mode 100644
index 00000000..36eddf7f
--- /dev/null
+++ b/src/components/motion-reveal.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { motion } from "framer-motion";
+import type { ReactNode } from "react";
+
+interface RevealProps {
+ children: ReactNode;
+ className?: string;
+ delay?: number;
+}
+
+export function Reveal({ children, className, delay = 0 }: RevealProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function RevealStagger({ children, className }: RevealProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function RevealItem({ children, className }: RevealProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/nav-dropdown.tsx b/src/components/nav-dropdown.tsx
index 5210bcd6..e84980ef 100644
--- a/src/components/nav-dropdown.tsx
+++ b/src/components/nav-dropdown.tsx
@@ -1,5 +1,9 @@
+"use client";
+
+import { AnimatePresence, motion } from "framer-motion";
import Image from "next/image";
-import type { ReactNode } from "react";
+import { type ReactNode, useEffect, useRef, useState } from "react";
+import { dropdownVariants } from "@/lib/motion";
interface NavDropdownProps {
label: string;
@@ -8,11 +12,37 @@ interface NavDropdownProps {
}
export function NavDropdown({ label, icon, children }: NavDropdownProps) {
+ const [open, setOpen] = useState(false);
+ const ref = useRef
(null);
+
+ useEffect(() => {
+ function onKeyDown(e: KeyboardEvent) {
+ if (e.key === "Escape") setOpen(false);
+ }
+ if (open) document.addEventListener("keydown", onKeyDown);
+ return () => document.removeEventListener("keydown", onKeyDown);
+ }, [open]);
+
+ useEffect(() => {
+ function onOutsideClick(e: MouseEvent) {
+ if (ref.current && !ref.current.contains(e.target as Node)) {
+ setOpen(false);
+ }
+ }
+ if (open) {
+ document.addEventListener("mousedown", onOutsideClick);
+ }
+ return () => document.removeEventListener("mousedown", onOutsideClick);
+ }, [open]);
+
return (
-
-
+
-
- {children}
-
-
+
+
+
+ {open && (
+
+ {children}
+
+ )}
+
+
);
}
diff --git a/src/components/public/radio-player.tsx b/src/components/public/radio-player.tsx
index 445a17a4..2630235a 100644
--- a/src/components/public/radio-player.tsx
+++ b/src/components/public/radio-player.tsx
@@ -1,5 +1,6 @@
"use client";
+import { motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
/**
@@ -228,7 +229,10 @@ export default function RadioPlayer() {
const border = "color-mix(in srgb, var(--color-text-muted) 18%, transparent)";
return (
-
-
+
);
}
diff --git a/src/lib/motion.ts b/src/lib/motion.ts
new file mode 100644
index 00000000..85d08fd6
--- /dev/null
+++ b/src/lib/motion.ts
@@ -0,0 +1,115 @@
+import type { Variants } from "framer-motion";
+
+export const fadeIn: Variants = {
+ hidden: { opacity: 0 },
+ visible: { opacity: 1, transition: { duration: 0.4 } },
+};
+
+export const fadeInUp: Variants = {
+ hidden: { opacity: 0, y: 20 },
+ visible: { opacity: 1, y: 0, transition: { duration: 0.4 } },
+};
+
+export const fadeInDown: Variants = {
+ hidden: { opacity: 0, y: -12 },
+ visible: { opacity: 1, y: 0, transition: { duration: 0.3 } },
+};
+
+export const fadeInLeft: Variants = {
+ hidden: { opacity: 0, x: -20 },
+ visible: { opacity: 1, x: 0, transition: { duration: 0.35 } },
+};
+
+export const fadeInRight: Variants = {
+ hidden: { opacity: 0, x: 20 },
+ visible: { opacity: 1, x: 0, transition: { duration: 0.35 } },
+};
+
+export const scaleIn: Variants = {
+ hidden: { opacity: 0, scale: 0.95 },
+ visible: { opacity: 1, scale: 1, transition: { duration: 0.25 } },
+};
+
+export const slideUp: Variants = {
+ hidden: { opacity: 0, y: 30 },
+ visible: {
+ opacity: 1,
+ y: 0,
+ transition: { duration: 0.4, ease: [0.25, 0.1, 0.25, 1] },
+ },
+};
+
+export const staggerContainer: Variants = {
+ hidden: {},
+ visible: {
+ transition: {
+ staggerChildren: 0.07,
+ delayChildren: 0.1,
+ },
+ },
+};
+
+export const staggerItem: Variants = {
+ hidden: { opacity: 0, y: 16 },
+ visible: { opacity: 1, y: 0, transition: { duration: 0.35 } },
+};
+
+export const pageTransition: Variants = {
+ initial: { opacity: 0, y: 8 },
+ enter: {
+ opacity: 1,
+ y: 0,
+ transition: { duration: 0.35, ease: [0.25, 0.1, 0.25, 1] },
+ },
+ exit: { opacity: 0, y: -8, transition: { duration: 0.2 } },
+};
+
+export const dropdownVariants: Variants = {
+ hidden: { opacity: 0, y: -6, scale: 0.97 },
+ visible: {
+ opacity: 1,
+ y: 0,
+ scale: 1,
+ transition: { duration: 0.18, ease: "easeOut" },
+ },
+ exit: { opacity: 0, y: -4, scale: 0.97, transition: { duration: 0.12 } },
+};
+
+export const mobileMenuVariants: Variants = {
+ hidden: { opacity: 0, scale: 0.96, y: -8 },
+ visible: {
+ opacity: 1,
+ scale: 1,
+ y: 0,
+ transition: { duration: 0.2, ease: "easeOut" },
+ },
+ exit: { opacity: 0, scale: 0.96, y: -6, transition: { duration: 0.15 } },
+};
+
+export const modalOverlayVariants: Variants = {
+ hidden: { opacity: 0 },
+ visible: { opacity: 1, transition: { duration: 0.2 } },
+ exit: { opacity: 0, transition: { duration: 0.15 } },
+};
+
+export const modalContentVariants: Variants = {
+ hidden: { opacity: 0, scale: 0.95, y: 10 },
+ visible: {
+ opacity: 1,
+ scale: 1,
+ y: 0,
+ transition: { duration: 0.25, ease: [0.25, 0.1, 0.25, 1] },
+ },
+ exit: { opacity: 0, scale: 0.95, y: 10, transition: { duration: 0.15 } },
+};
+
+export const hoverScale = {
+ whileHover: { scale: 1.03 },
+ whileTap: { scale: 0.97 },
+ transition: { type: "spring" as const, stiffness: 400, damping: 17 },
+};
+
+export const hoverLift = {
+ whileHover: { y: -3, transition: { duration: 0.2 } },
+ whileTap: { y: 0 },
+};