Simplify mobile menu animation
This commit is contained in:
@@ -7,7 +7,7 @@ All notable changes to the New Optic website will be documented in this file.
|
||||
### Changed
|
||||
|
||||
- Removed the desktop liquid glass WebGL layer from navbar and CTA controls so text remains readable and glass surfaces do not show dark rendering artifacts while scrolling.
|
||||
- Made the mobile hamburger menu open with springy jelly motion and added a draggable corner grip that stretches the menu before snapping back.
|
||||
- Replaced the mobile hamburger dropdown with a fast, solid menu animation without bounce or stretch effects.
|
||||
|
||||
## [1.0.0] - 2026-05-16
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useMotionValue, useSpring, useTransform } from "framer-motion";
|
||||
import { Grip, Menu, X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { type MouseEvent, useState } from "react";
|
||||
import { business, type Locale } from "@/config/business";
|
||||
@@ -11,22 +11,6 @@ import PhysicsButton from "./PhysicsButton";
|
||||
|
||||
export default function Navbar({ locale, onLocaleChange, t, whatsappUrl }: { locale: Locale; onLocaleChange: (locale: Locale) => void; t: Messages; whatsappUrl: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const stretchX = useMotionValue(0);
|
||||
const stretchY = useMotionValue(0);
|
||||
const springX = useSpring(stretchX, { stiffness: 260, damping: 13, mass: 0.34 });
|
||||
const springY = useSpring(stretchY, { stiffness: 260, damping: 13, mass: 0.34 });
|
||||
const menuScaleX = useTransform(springX, [-70, 0, 96], [0.9, 1, 1.22]);
|
||||
const menuScaleY = useTransform(springY, [-54, 0, 104], [0.88, 1, 1.18]);
|
||||
const menuSkewX = useTransform(springX, [-70, 0, 96], [-5.5, 0, 7]);
|
||||
const menuRadius = useTransform(springY, [-54, 0, 104], ["2.65rem", "2rem", "2.9rem"]);
|
||||
const sheenX = useTransform(springX, [-70, 0, 96], ["-18%", "0%", "20%"]);
|
||||
const cornerX = useSpring(stretchX, { stiffness: 520, damping: 20, mass: 0.24 });
|
||||
const cornerY = useSpring(stretchY, { stiffness: 520, damping: 20, mass: 0.24 });
|
||||
|
||||
function releaseMenuStretch() {
|
||||
stretchX.set(0);
|
||||
stretchY.set(0);
|
||||
}
|
||||
|
||||
function scrollToSection(event: MouseEvent<HTMLAnchorElement>, href: string) {
|
||||
if (!href.startsWith("#")) return;
|
||||
@@ -70,8 +54,8 @@ export default function Navbar({ locale, onLocaleChange, t, whatsappUrl }: { loc
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
whileTap={{ scaleX: 1.22, scaleY: 0.82 }}
|
||||
transition={{ type: "spring", stiffness: 520, damping: 14, mass: 0.32 }}
|
||||
whileTap={{ scale: 0.94 }}
|
||||
transition={{ duration: 0.08 }}
|
||||
className="grid size-11 place-items-center rounded-full bg-white/70 text-ink outline-none transition focus-visible:ring-2 focus-visible:ring-optical/45 focus-visible:ring-offset-2"
|
||||
aria-label={t.nav.menu}
|
||||
aria-expanded={open}
|
||||
@@ -84,64 +68,31 @@ export default function Navbar({ locale, onLocaleChange, t, whatsappUrl }: { loc
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -28, scaleX: 0.18, scaleY: 0.08, skewX: locale === "ar" ? -10 : 10, borderRadius: "999px" }}
|
||||
animate={{ opacity: 1, y: 0, scaleX: [0.18, 1.16, 0.96, 1], scaleY: [0.08, 1.2, 0.94, 1], skewX: [locale === "ar" ? -10 : 10, locale === "ar" ? 5 : -5, 0], borderRadius: ["999px", "2.85rem", "1.65rem", "2rem"] }}
|
||||
exit={{ opacity: 0, y: -18, scaleX: 0.38, scaleY: 0.14, skewX: locale === "ar" ? -7 : 7, borderRadius: "999px" }}
|
||||
transition={{ duration: 0.42, ease: [0.16, 1, 0.3, 1] }}
|
||||
style={{ transformOrigin: locale === "ar" ? "top left" : "top right" }}
|
||||
className="mx-auto mt-3 max-w-7xl md:hidden"
|
||||
initial={{ opacity: 0, y: -8, scale: 0.985 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -6, scale: 0.99 }}
|
||||
transition={{ duration: 0.13, ease: "easeOut" }}
|
||||
className="relative mx-auto mt-3 max-w-7xl overflow-hidden rounded-[2rem] border border-ink/8 bg-white p-4 shadow-glass md:hidden"
|
||||
>
|
||||
<motion.div
|
||||
style={{ scaleX: menuScaleX, scaleY: menuScaleY, skewX: menuSkewX, borderRadius: menuRadius, transformOrigin: locale === "ar" ? "bottom left" : "bottom right" }}
|
||||
onPointerLeave={releaseMenuStretch}
|
||||
className="relative w-full overflow-hidden border border-ink/8 bg-white p-4 pb-10 shadow-glass"
|
||||
>
|
||||
<motion.div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -inset-x-10 top-1 h-10 rounded-full bg-silver/35 blur-xl"
|
||||
initial={{ opacity: 0, scaleX: 0.4 }}
|
||||
animate={{ opacity: [0.22, 0.9, 0.38], scaleX: [0.3, 1.28, 0.88], x: ["-20%", "12%", "0%"] }}
|
||||
transition={{ duration: 0.38, ease: [0.22, 1, 0.36, 1] }}
|
||||
/>
|
||||
<motion.div aria-hidden className="pointer-events-none absolute bottom-0 right-0 h-28 w-28 rounded-full bg-optical/8 blur-2xl rtl:left-0 rtl:right-auto" style={{ x: sheenX, y: springY }} />
|
||||
<div className="relative z-[2] grid gap-2">
|
||||
{t.nav.links.map((link) => (
|
||||
<motion.a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={(event) => scrollToSection(event, link.href)}
|
||||
initial={{ opacity: 0, x: locale === "ar" ? -28 : 28, scaleX: 0.86 }}
|
||||
animate={{ opacity: 1, x: 0, scaleX: 1 }}
|
||||
transition={{ type: "spring", stiffness: 680, damping: 22, mass: 0.26 }}
|
||||
whileTap={{ scaleX: 1.12, scaleY: 0.86 }}
|
||||
className="rounded-2xl px-4 py-3 text-sm font-semibold text-ink/72 hover:bg-white"
|
||||
>
|
||||
{link.label}
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative z-[2] mt-4 flex items-center justify-between gap-3">
|
||||
<LanguageSwitcher locale={locale} onLocaleChange={onLocaleChange} />
|
||||
</div>
|
||||
<motion.button
|
||||
type="button"
|
||||
aria-label={`${t.nav.menu} grip`}
|
||||
drag
|
||||
dragSnapToOrigin
|
||||
dragConstraints={{ left: -56, right: 72, top: -46, bottom: 82 }}
|
||||
dragElastic={0.94}
|
||||
onDrag={(_, info) => {
|
||||
stretchX.set(Math.max(-70, Math.min(96, info.offset.x)));
|
||||
stretchY.set(Math.max(-54, Math.min(104, info.offset.y)));
|
||||
}}
|
||||
onDragEnd={releaseMenuStretch}
|
||||
onPointerUp={releaseMenuStretch}
|
||||
style={{ x: cornerX, y: cornerY }}
|
||||
className="absolute bottom-3 right-3 z-[3] grid size-9 touch-none cursor-grab place-items-center rounded-full border border-ink/10 bg-white/80 text-ink/55 shadow-sm outline-none transition focus-visible:ring-2 focus-visible:ring-optical/35 active:cursor-grabbing rtl:left-3 rtl:right-auto"
|
||||
>
|
||||
<Grip size={15} />
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
<div className="grid gap-2">
|
||||
{t.nav.links.map((link) => (
|
||||
<motion.a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={(event) => scrollToSection(event, link.href)}
|
||||
initial={{ opacity: 0, x: locale === "ar" ? -8 : 8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.1, ease: "easeOut" }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="rounded-2xl px-4 py-3 text-sm font-semibold text-ink/72 hover:bg-smoke"
|
||||
>
|
||||
{link.label}
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<LanguageSwitcher locale={locale} onLocaleChange={onLocaleChange} />
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
Reference in New Issue
Block a user