Polish header entrance motion

This commit is contained in:
MOH
2026-03-10 17:14:52 +01:00
parent 5b51a2a063
commit 8bf2121ffa
2 changed files with 143 additions and 122 deletions
+100 -97
View File
@@ -24,47 +24,24 @@ const navItems = [
{ key: "contact", path: "/contact" }, { key: "contact", path: "/contact" },
]; ];
const brandTitleCharacters = [
...Array.from("MOH").map((character) => ({ character, tone: "base" as const })),
{ character: " ", tone: "space" as const },
...Array.from("FARAWATI").map((character) => ({ character, tone: "accent" as const })),
];
const brandShellVariants = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.035,
delayChildren: 0.18,
},
},
} as const;
const brandCharacterVariants = {
hidden: {
opacity: 0,
y: 18,
rotateX: -80,
filter: "blur(8px)",
},
visible: {
opacity: 1,
y: 0,
rotateX: 0,
filter: "blur(0px)",
transition: {
type: "spring" as const,
stiffness: 280,
damping: 20,
mass: 0.8,
},
},
} as const;
type SiteHeaderProps = { type SiteHeaderProps = {
isAdmin?: boolean; isAdmin?: boolean;
lightLogoUrl?: string | null; lightLogoUrl?: string | null;
darkLogoUrl?: string | null; darkLogoUrl?: string | null;
}; };
function getDirectionalReveal(direction: "rtl" | "ltr") {
return direction === "rtl"
? {
initial: { opacity: 0, x: -16, clipPath: "inset(0 0 0 100%)", filter: "blur(10px)" },
animate: { opacity: 1, x: 0, y: 0, clipPath: "inset(0 0 0 0)", filter: "blur(0px)" },
}
: {
initial: { opacity: 0, x: 16, clipPath: "inset(0 100% 0 0)", filter: "blur(10px)" },
animate: { opacity: 1, x: 0, y: 0, clipPath: "inset(0 0 0 0)", filter: "blur(0px)" },
};
}
function isNavItemActive(currentPath: string, itemPath: string) { function isNavItemActive(currentPath: string, itemPath: string) {
if (itemPath === "/") { if (itemPath === "/") {
return currentPath === "/"; return currentPath === "/";
@@ -134,6 +111,29 @@ export function SiteHeader({
const t = useTranslations("navigation"); const t = useTranslations("navigation");
const reducedMotion = useReducedMotion(); const reducedMotion = useReducedMotion();
const isArabic = locale === "ar"; const isArabic = locale === "ar";
const languageDirection = isArabic ? "rtl" : "ltr";
const titleMotion = getDirectionalReveal(languageDirection);
const subtitleMotion = getDirectionalReveal(languageDirection);
const navEntranceInitial = reducedMotion ? false : { opacity: 0, y: -14, scale: 0.985, filter: "blur(8px)" };
const navEntranceVisible = { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" };
const controlEntranceInitial = reducedMotion
? false
: {
opacity: 0,
x: languageDirection === "rtl" ? -18 : 18,
scale: 0.98,
filter: "blur(8px)",
};
const controlEntranceVisible = { opacity: 1, x: 0, scale: 1, filter: "blur(0px)" };
const mobileMenuInitial = reducedMotion
? false
: {
opacity: 0,
x: languageDirection === "rtl" ? -14 : 14,
scale: 0.94,
filter: "blur(8px)",
};
const mobileMenuVisible = { opacity: 1, x: 0, scale: 1, filter: "blur(0px)" };
const brandSubtitle = isArabic const brandSubtitle = isArabic
? "مــــــطـــــــــــــور ويــــــــــب | فــــــــــــريــــــلانــــــســــــر" ? "مــــــطـــــــــــــور ويــــــــــب | فــــــــــــريــــــلانــــــســــــر"
: "Webdesigner Bremen | Freelancer"; : "Webdesigner Bremen | Freelancer";
@@ -182,51 +182,39 @@ export function SiteHeader({
priority priority
className="relative h-10 opacity-95" className="relative h-10 opacity-95"
replayKey={logoReplayKey} replayKey={logoReplayKey}
direction={languageDirection}
/> />
<motion.span <span
initial={isArabic ? false : "hidden"} key={`${locale}-${logoReplayKey}`}
animate="visible"
variants={reducedMotion || isArabic ? undefined : brandShellVariants}
className="flex flex-col justify-center" className="flex flex-col justify-center"
> >
{isArabic ? (
<motion.span
initial={reducedMotion ? false : { opacity: 0, x: 16, filter: "blur(8px)" }}
animate={{ opacity: 1, x: 0, filter: "blur(0px)" }}
transition={reducedMotion ? { duration: 0 } : { duration: 0.42, delay: 0.12, ease: [0.22, 1, 0.36, 1] }}
className="text-[1.28rem] font-black leading-[1.2] whitespace-nowrap"
dir="rtl"
>
<span className="text-[hsl(var(--hero-ink))] dark:text-foreground">مــــحـــمــد</span>
{" "}
<span className="text-primary">فــــرواتـــي</span>
</motion.span>
) : (
<span className="flex flex-wrap items-center text-[1.28rem] font-black leading-[1.2]">
{brandTitleCharacters.map(({ character, tone }, index) => (
<motion.span
key={`${character}-${index}`}
variants={reducedMotion ? undefined : brandCharacterVariants}
className={cn(
character === " " ? "mr-[0.11em] w-[0.11em]" : "mr-[0.005em] inline-block",
tone === "base" ? "text-[hsl(var(--hero-ink))] dark:text-foreground" : "",
tone === "accent" ? "text-primary" : "",
)}
>
{character === " " ? "\u00A0" : character}
</motion.span>
))}
</span>
)}
<motion.span <motion.span
initial={ initial={reducedMotion ? false : titleMotion.initial}
reducedMotion animate={titleMotion.animate}
? false transition={reducedMotion ? { duration: 0 } : { duration: 0.7, delay: 0.12, ease: [0.22, 1, 0.36, 1] }}
: isArabic className={cn(
? { opacity: 0, x: -16, clipPath: "inset(0 0 0 100%)", filter: "blur(10px)" } "text-[1.28rem] font-black leading-[1.2] whitespace-nowrap",
: { opacity: 0, x: 16, clipPath: "inset(0 100% 0 0)", filter: "blur(10px)" } isArabic ? "text-right" : "",
} )}
animate={{ opacity: 1, x: 0, y: 0, clipPath: "inset(0 0 0% 0)", filter: "blur(0px)" }} dir={isArabic ? "rtl" : undefined}
>
{isArabic ? (
<>
<span className="text-[hsl(var(--hero-ink))] dark:text-foreground">مــــحـــمــد</span>
{" "}
<span className="text-primary">فــــرواتـــي</span>
</>
) : (
<>
<span className="text-[hsl(var(--hero-ink))] dark:text-foreground">MOH</span>
{" "}
<span className="text-primary">FARAWATI</span>
</>
)}
</motion.span>
<motion.span
initial={reducedMotion ? false : subtitleMotion.initial}
animate={subtitleMotion.animate}
transition={reducedMotion ? { duration: 0 } : { duration: 0.7, delay: 0.34, ease: [0.22, 1, 0.36, 1] }} transition={reducedMotion ? { duration: 0 } : { duration: 0.7, delay: 0.34, ease: [0.22, 1, 0.36, 1] }}
className={cn( className={cn(
"-mt-0.5 text-[0.65rem] font-light leading-[1.2] tracking-[0.008em] text-foreground/78", "-mt-0.5 text-[0.65rem] font-light leading-[1.2] tracking-[0.008em] text-foreground/78",
@@ -236,15 +224,19 @@ export function SiteHeader({
> >
{brandSubtitle} {brandSubtitle}
</motion.span> </motion.span>
</motion.span> </span>
</Link> </Link>
</motion.div> </motion.div>
<div className="col-start-2 hidden justify-center lg:flex"> <div className="col-start-2 hidden justify-center lg:flex">
<motion.nav <motion.nav
initial={false} initial={navEntranceInitial}
animate={isScrolled ? { y: -1, scale: 0.985 } : { y: 0, scale: 1 }} animate={
transition={reducedMotion ? { duration: 0 } : { duration: 0.3, ease: [0.22, 1, 0.36, 1] }} isScrolled
? { opacity: 1, y: -1, scale: 0.985, filter: "blur(0px)" }
: navEntranceVisible
}
transition={reducedMotion ? { duration: 0 } : { duration: 0.56, delay: 0.22, ease: [0.22, 1, 0.36, 1] }}
className={cn( className={cn(
"inline-flex items-center gap-1 rounded-pill border border-border/70 bg-background/80 p-1.5 backdrop-blur-chrome", "inline-flex items-center gap-1 rounded-pill border border-border/70 bg-background/80 p-1.5 backdrop-blur-chrome",
isArabic ? "w-auto max-w-none" : "min-w-[27rem] max-w-[42rem]", isArabic ? "w-auto max-w-none" : "min-w-[27rem] max-w-[42rem]",
@@ -255,9 +247,13 @@ export function SiteHeader({
</div> </div>
<motion.div <motion.div
initial={false} initial={controlEntranceInitial}
animate={isScrolled ? { opacity: 0, x: 28 } : { opacity: 1, x: 0 }} animate={
transition={reducedMotion ? { duration: 0 } : { duration: 0.3, ease: [0.22, 1, 0.36, 1] }} isScrolled
? { opacity: 0, x: languageDirection === "rtl" ? -28 : 28, scale: 0.98, filter: "blur(4px)" }
: controlEntranceVisible
}
transition={reducedMotion ? { duration: 0 } : { duration: 0.56, delay: 0.3, ease: [0.22, 1, 0.36, 1] }}
className={cn( className={cn(
"col-start-3 hidden items-center justify-end lg:flex", "col-start-3 hidden items-center justify-end lg:flex",
isScrolled ? "pointer-events-none invisible" : "", isScrolled ? "pointer-events-none invisible" : "",
@@ -289,22 +285,29 @@ export function SiteHeader({
</div> </div>
</motion.div> </motion.div>
<Button <motion.div
type="button" initial={mobileMenuInitial}
onClick={() => setIsOpen((open) => !open)} animate={mobileMenuVisible}
variant="ghost" transition={reducedMotion ? { duration: 0 } : { duration: 0.52, delay: 0.2, ease: [0.22, 1, 0.36, 1] }}
size="icon" className="col-start-3 ml-auto justify-self-end lg:hidden"
className="col-start-3 ml-auto h-11 w-11 justify-self-end rounded-pill border border-border/70 bg-background/80 text-foreground backdrop-blur-chrome hover:bg-accent lg:hidden"
aria-label={isOpen ? t("closeMenu") : t("openMenu")}
> >
<motion.span <Button
animate={{ rotate: isOpen ? 180 : 0, scale: isOpen ? 0.96 : 1 }} type="button"
transition={{ duration: 0.2, ease: "easeOut" }} onClick={() => setIsOpen((open) => !open)}
className="flex items-center justify-center" variant="ghost"
size="icon"
className="h-11 w-11 rounded-pill border border-border/70 bg-background/80 text-foreground backdrop-blur-chrome hover:bg-accent"
aria-label={isOpen ? t("closeMenu") : t("openMenu")}
> >
{isOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />} <motion.span
</motion.span> animate={{ rotate: isOpen ? 180 : 0, scale: isOpen ? 0.96 : 1 }}
</Button> transition={{ duration: 0.2, ease: "easeOut" }}
className="flex items-center justify-center"
>
{isOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
</motion.span>
</Button>
</motion.div>
</div> </div>
</Container> </Container>
+43 -25
View File
@@ -14,6 +14,7 @@ type SiteLogoProps = {
priority?: boolean; priority?: boolean;
className?: string; className?: string;
replayKey?: number; replayKey?: number;
direction?: "rtl" | "ltr";
}; };
export function SiteLogo({ export function SiteLogo({
@@ -23,11 +24,15 @@ export function SiteLogo({
priority = false, priority = false,
className, className,
replayKey = 0, replayKey = 0,
direction = "ltr",
}: SiteLogoProps) { }: SiteLogoProps) {
const { theme, resolvedTheme } = useTheme(); const { theme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const controls = useAnimationControls(); const containerControls = useAnimationControls();
const spinControls = useAnimationControls();
const reducedMotion = useReducedMotion(); const reducedMotion = useReducedMotion();
const directionalOffset = direction === "rtl" ? 42 : -42;
const spinStart = direction === "rtl" ? -360 : 360;
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
@@ -38,29 +43,35 @@ export function SiteLogo({
return; return;
} }
controls.set({ containerControls.set({
opacity: 0, opacity: 0,
x: -42, x: directionalOffset,
scale: 0.88, scale: 0.88,
rotate: -360,
filter: "blur(8px)", filter: "blur(8px)",
}); });
spinControls.set({ rotate: 0 });
void controls.start({ void containerControls.start({
opacity: 1, opacity: 1,
x: 0, x: 0,
scale: 1, scale: 1,
rotate: 0,
filter: "blur(0px)", filter: "blur(0px)",
transition: { transition: {
x: { duration: 0.78, ease: [0.22, 1, 0.36, 1] }, x: { duration: 0.78, ease: [0.22, 1, 0.36, 1] },
opacity: { duration: 0.46 }, opacity: { duration: 0.46 },
rotate: { duration: 0.56, ease: "easeOut" },
scale: { duration: 0.72, ease: [0.22, 1, 0.36, 1] }, scale: { duration: 0.72, ease: [0.22, 1, 0.36, 1] },
filter: { duration: 0.42, ease: [0.22, 1, 0.36, 1] }, filter: { duration: 0.42, ease: [0.22, 1, 0.36, 1] },
}, },
}); });
}, [controls, reducedMotion, replayKey]);
void spinControls.start({
rotate: spinStart,
transition: {
duration: 0.78,
ease: [0.22, 1, 0.36, 1],
},
});
}, [containerControls, directionalOffset, reducedMotion, replayKey, spinControls, spinStart]);
const activeTheme = theme === "system" ? resolvedTheme : theme; const activeTheme = theme === "system" ? resolvedTheme : theme;
const isDark = mounted && activeTheme === "dark"; const isDark = mounted && activeTheme === "dark";
@@ -69,25 +80,32 @@ export function SiteLogo({
return ( return (
<motion.span <motion.span
className="block [transform-style:preserve-3d]" className="block transform-gpu [transform-style:preserve-3d]"
initial={reducedMotion ? false : undefined} initial={reducedMotion ? false : undefined}
animate={reducedMotion ? undefined : controls} animate={reducedMotion ? undefined : containerControls}
whileHover={{ scale: 1.05, y: -2, rotate: -2, filter: "drop-shadow(0 16px 28px rgba(15,23,42,0.14))" }} transition={{ duration: 0.32, ease: [0.22, 1, 0.36, 1] }}
whileTap={{ scale: 0.9, rotate: 6, y: 1, filter: "drop-shadow(0 8px 18px rgba(15,23,42,0.18))" }} whileHover={reducedMotion ? undefined : { scale: 1.03, y: -1.5, rotate: -1.25 }}
whileTap={reducedMotion ? undefined : { scale: 0.97, rotate: 1.5, y: 0.5 }}
> >
<Image <motion.span
src={src} className="block origin-center"
alt={alt} initial={reducedMotion ? false : { rotate: 0 }}
width={360} animate={reducedMotion ? undefined : spinControls}
height={92} >
sizes="(min-width: 1280px) 360px, (min-width: 1024px) 320px, (min-width: 640px) 280px, 240px" <Image
className={cn( src={src}
"h-9 w-auto object-contain object-left drop-shadow-[0_10px_24px_rgba(15,23,42,0.12)] transition-[filter,opacity,transform] duration-300 ease-out sm:h-10 lg:h-11 xl:h-12", alt={alt}
mounted ? "opacity-100" : "opacity-0", width={360}
className, height={92}
)} sizes="240px"
priority={priority} className={cn(
/> "h-10 w-auto object-contain object-left drop-shadow-[0_10px_24px_rgba(15,23,42,0.12)] transition-opacity duration-300 ease-out",
mounted ? "opacity-100" : "opacity-0",
className,
)}
priority={priority}
/>
</motion.span>
</motion.span> </motion.span>
); );
} }