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" > -
+
{username}
-
@@ -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 }, +};