Refine home hero and header experience

This commit is contained in:
MOH
2026-03-09 08:39:49 +01:00
parent c009f71e39
commit 9632958e41
6 changed files with 166 additions and 80 deletions
+14 -16
View File
@@ -368,34 +368,32 @@ html[lang="ar"] {
.hero-title-accent { .hero-title-accent {
background-image: linear-gradient( background-image: linear-gradient(
120deg, 135deg,
hsl(var(--hero-ink)) 0%, hsl(var(--primary)) 0%,
hsl(var(--hero-ink)) 24%, color-mix(in srgb, hsl(var(--primary)) 82%, hsl(var(--foreground))) 100%
hsl(var(--primary) / 0.58) 52%,
hsl(var(--hero-right) / 0.34) 78%,
hsl(var(--hero-ink)) 100%
); );
background-size: 220% 220%;
background-clip: text; background-clip: text;
-webkit-background-clip: text; -webkit-background-clip: text;
color: transparent; color: transparent;
animation: hero-title-shift 8s ease-in-out infinite alternate;
} }
.hero-title-accent-soft { .hero-title-accent-soft {
color: hsl(var(--hero-ink));
}
.dark .hero-title-accent {
background-image: linear-gradient( background-image: linear-gradient(
120deg, 135deg,
hsl(var(--hero-ink)) 0%, hsl(var(--primary)) 0%,
hsl(var(--hero-ink)) 34%, color-mix(in srgb, hsl(var(--primary)) 74%, hsl(var(--foreground))) 100%
hsl(var(--hero-left) / 0.26) 62%,
hsl(var(--primary) / 0.24) 82%,
hsl(var(--hero-ink)) 100%
); );
background-size: 220% 220%;
background-clip: text; background-clip: text;
-webkit-background-clip: text; -webkit-background-clip: text;
color: transparent; color: transparent;
animation: hero-title-shift 10s ease-in-out infinite alternate; }
.dark .hero-title-accent-soft {
color: hsl(var(--hero-ink));
} }
.hero-scroll-link { .hero-scroll-link {
+72 -31
View File
@@ -1,7 +1,8 @@
import { motion, useReducedMotion } from "framer-motion";
import { import {
HeroContentMotion, HeroContentMotion,
HeroMotionItem, HeroMotionItem,
HeroScrollLink,
HeroShell, HeroShell,
HeroTitle, HeroTitle,
} from "@/components/layout/site-hero"; } from "@/components/layout/site-hero";
@@ -19,36 +20,83 @@ export function HomeHero({
title, title,
description, description,
}: HomeHeroProps) { }: HomeHeroProps) {
const reducedMotion = useReducedMotion();
const titleLines = title.split("\n").filter(Boolean); const titleLines = title.split("\n").filter(Boolean);
const isArabic = locale === "ar"; const isArabic = locale === "ar";
const lines = titleLines.map((line, index) => ({ const lines = isArabic
text: line, ? [
tone: isArabic {
? index === 0 text: titleLines[0] ?? "",
? "accent" align: "center" as const,
: "default" segments: [
: index === titleLines.length - 1 { text: "مطوّر ", tone: "accent" as const },
? "accent" { text: "واجهات", tone: "soft" as const },
: index === 0 { text: " ويب", tone: "accent" as const },
? "soft" ],
: "default", },
align: isArabic {
? "center" text: titleLines[1] ?? "",
: index === 1 align: "center" as const,
? "end" segments: [
: "start", { text: "ومصمّم ", tone: "soft" as const },
})) as { { text: "جرافيك", tone: "accent" as const },
text: string; ],
tone: "default" | "accent" | "soft"; },
align: "start" | "center" | "end"; ]
}[]; : [
{
text: titleLines[0] ?? "",
tone: "soft" as const,
align: "start" as const,
},
{
text: titleLines[1] ?? "",
tone: "accent" as const,
align: "end" as const,
},
{
text: titleLines[2] ?? "",
segments: [
{ text: "& ", tone: "accent" as const },
{ text: "designer", tone: "soft" as const },
],
align: "start" as const,
},
];
return ( return (
<HeroShell showBridges> <HeroShell showBridges>
<HeroContentMotion variant="home"> <HeroContentMotion variant="home">
<HeroMotionItem variant="home"> <HeroMotionItem variant="home">
<p className="max-w-[34rem] text-sm font-medium tracking-[0.12em] text-foreground/62 sm:text-base"> <p className="inline-flex items-center gap-1.5 rounded-pill border border-border/70 bg-background/75 px-3 py-1 text-[0.84rem] font-medium tracking-[-0.01em] text-foreground/82 shadow-xs backdrop-blur-chrome sm:px-3.5 sm:py-1 sm:text-[0.9rem]">
{kicker} <span>{kicker}</span>
<motion.span
aria-hidden="true"
className="text-[0.95rem] leading-none"
initial={reducedMotion ? false : { rotate: 0, scale: 1 }}
animate={
reducedMotion
? undefined
: {
rotate: [0, 16, -10, 16, -4, 0],
scale: [1, 1.08, 1.08, 1.08, 1.04, 1],
}
}
transition={
reducedMotion
? undefined
: {
duration: 1.4,
delay: 0.5,
repeat: Number.POSITIVE_INFINITY,
repeatDelay: 2.8,
ease: "easeInOut",
}
}
style={{ transformOrigin: "70% 70%" }}
>
👋
</motion.span>
</p> </p>
</HeroMotionItem> </HeroMotionItem>
<HeroTitle locale={locale} lines={lines} /> <HeroTitle locale={locale} lines={lines} />
@@ -57,13 +105,6 @@ export function HomeHero({
{description} {description}
</p> </p>
</HeroMotionItem> </HeroMotionItem>
<HeroMotionItem variant="home" className="mt-9 flex items-center justify-center">
<HeroScrollLink
href="#home-content"
label="Scroll to content"
className="text-[hsl(var(--hero-ink)/0.78)] dark:text-foreground/82"
/>
</HeroMotionItem>
</HeroContentMotion> </HeroContentMotion>
</HeroShell> </HeroShell>
); );
+9 -13
View File
@@ -128,6 +128,7 @@ export function SiteHeader({
}: SiteHeaderProps) { }: SiteHeaderProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const [logoReplayKey, setLogoReplayKey] = useState(0);
const pathname = usePathname(); const pathname = usePathname();
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("navigation"); const t = useTranslations("navigation");
@@ -137,7 +138,7 @@ export function SiteHeader({
? "مــــــطـــــــــــــور ويــــــــــب | فــــــــــــريــــــلانــــــســــــر" ? "مــــــطـــــــــــــور ويــــــــــب | فــــــــــــريــــــلانــــــســــــر"
: "Webdesigner Bremen | Freelancer"; : "Webdesigner Bremen | Freelancer";
const desktopControlRailClassName = const desktopControlRailClassName =
"inline-flex items-center gap-1 rounded-pill border border-border/70 bg-background/80 p-1 shadow-panel backdrop-blur-chrome"; "inline-flex items-center gap-1 rounded-pill border border-border/70 bg-background/80 p-1 backdrop-blur-chrome";
const desktopControlButtonClassName = const desktopControlButtonClassName =
"h-9 w-9 rounded-pill border border-transparent p-0 text-foreground/80 hover:bg-accent hover:text-foreground"; "h-9 w-9 rounded-pill border border-transparent p-0 text-foreground/80 hover:bg-accent hover:text-foreground";
const mobileControlButtonClassName = const mobileControlButtonClassName =
@@ -158,7 +159,7 @@ export function SiteHeader({
return ( return (
<header className="fixed inset-x-0 top-3 z-40 bg-transparent"> <header className="fixed inset-x-0 top-3 z-40 bg-transparent">
<Container className="max-w-[88rem]"> <Container className="max-w-[88rem] px-5 sm:px-6 lg:px-8">
<div className="relative grid min-h-[4.5rem] grid-cols-[auto_1fr_auto] items-center gap-3 lg:min-h-[5rem]"> <div className="relative grid min-h-[4.5rem] grid-cols-[auto_1fr_auto] items-center gap-3 lg:min-h-[5rem]">
<motion.div <motion.div
initial={false} initial={false}
@@ -172,12 +173,7 @@ export function SiteHeader({
<Link <Link
href={getLocalizedPath(locale)} href={getLocalizedPath(locale)}
className="inline-flex items-center gap-2.5 rounded-pill px-1 py-1 sm:gap-3 lg:py-2" className="inline-flex items-center gap-2.5 rounded-pill px-1 py-1 sm:gap-3 lg:py-2"
> onClick={() => setLogoReplayKey((value) => value + 1)}
<motion.span
initial={reducedMotion ? false : isArabic ? { opacity: 0, x: 18, scale: 0.82, rotate: 14, filter: "blur(10px)" } : { opacity: 0, x: -18, scale: 0.82, rotate: -14, filter: "blur(10px)" }}
animate={{ opacity: 1, x: 0, scale: 1, rotate: 0, filter: "blur(0px)" }}
transition={reducedMotion ? { duration: 0 } : { duration: 0.32, ease: [0.22, 1, 0.36, 1] }}
className="relative inline-flex items-center justify-center [transform-style:preserve-3d]"
> >
<SiteLogo <SiteLogo
lightLogoUrl={lightLogoUrl} lightLogoUrl={lightLogoUrl}
@@ -185,8 +181,8 @@ export function SiteHeader({
alt="mohfarawati" alt="mohfarawati"
priority priority
className="relative h-10 opacity-95" className="relative h-10 opacity-95"
replayKey={logoReplayKey}
/> />
</motion.span>
<motion.span <motion.span
initial={isArabic ? false : "hidden"} initial={isArabic ? false : "hidden"}
animate="visible" animate="visible"
@@ -250,7 +246,7 @@ export function SiteHeader({
animate={isScrolled ? { y: -1, scale: 0.985 } : { y: 0, scale: 1 }} animate={isScrolled ? { y: -1, scale: 0.985 } : { y: 0, scale: 1 }}
transition={reducedMotion ? { duration: 0 } : { duration: 0.3, ease: [0.22, 1, 0.36, 1] }} transition={reducedMotion ? { duration: 0 } : { duration: 0.3, 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 shadow-panel 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]",
)} )}
> >
@@ -298,7 +294,7 @@ export function SiteHeader({
onClick={() => setIsOpen((open) => !open)} onClick={() => setIsOpen((open) => !open)}
variant="ghost" variant="ghost"
size="icon" size="icon"
className="col-start-3 ml-auto h-11 w-11 justify-self-end rounded-pill border border-border/70 bg-background/80 text-foreground shadow-panel backdrop-blur-chrome hover:bg-accent 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")} aria-label={isOpen ? t("closeMenu") : t("openMenu")}
> >
<motion.span <motion.span
@@ -321,13 +317,13 @@ export function SiteHeader({
transition={{ duration: 0.22, ease: "easeOut" }} transition={{ duration: 0.22, ease: "easeOut" }}
className="bg-transparent lg:hidden" className="bg-transparent lg:hidden"
> >
<Container className="pb-2 pt-3"> <Container className="px-5 pb-2 pt-3 sm:px-6 lg:px-8">
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.98 }} initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }} exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.2, ease: "easeOut" }} transition={{ duration: 0.2, ease: "easeOut" }}
className="overflow-hidden rounded-surface border border-border/70 bg-card/95 shadow-panel backdrop-blur-chrome" className="overflow-hidden rounded-surface border border-border/70 bg-card/95 backdrop-blur-chrome"
> >
<motion.nav <motion.nav
initial="closed" initial="closed"
+25 -7
View File
@@ -12,8 +12,14 @@ import { cn } from "@/lib/utils";
type HeroTone = "default" | "accent" | "soft"; type HeroTone = "default" | "accent" | "soft";
type HeroLineAlign = "start" | "center" | "end"; type HeroLineAlign = "start" | "center" | "end";
type HeroTitleSegment = {
text: string;
tone?: HeroTone;
};
type HeroTitleLine = { type HeroTitleLine = {
text: string; text: string;
segments?: HeroTitleSegment[];
tone?: HeroTone; tone?: HeroTone;
align?: HeroLineAlign; align?: HeroLineAlign;
}; };
@@ -55,9 +61,9 @@ const toneClasses: Record<HeroTone, string> = {
}; };
const lineAlignClasses: Record<HeroLineAlign, string> = { const lineAlignClasses: Record<HeroLineAlign, string> = {
start: "self-start text-start", start: "self-center text-center lg:self-start lg:text-start",
center: "self-center text-center", center: "self-center text-center",
end: "self-end text-end", end: "self-center text-center lg:self-end lg:text-end",
}; };
export function HeroShell({ export function HeroShell({
@@ -97,7 +103,7 @@ export function HeroShell({
<div <div
className={cn( className={cn(
"mx-auto flex w-full flex-col items-center text-center", "mx-auto flex w-full flex-col items-center text-center",
variant === "home" ? "max-w-[58rem]" : "max-w-[46rem]", variant === "home" ? "max-w-[68rem]" : "max-w-[46rem]",
contentClassName, contentClassName,
)} )}
> >
@@ -183,9 +189,9 @@ export function HeroTitle({
"text-balance font-semibold text-[hsl(var(--hero-ink))]", "text-balance font-semibold text-[hsl(var(--hero-ink))]",
variant === "home" variant === "home"
? isArabic ? isArabic
? "mt-6 flex w-full max-w-[16rem] flex-col gap-y-1 text-[clamp(3rem,11vw,5.9rem)] leading-[1.06] tracking-[-0.03em] md:max-w-[22rem] lg:max-w-[32rem]" ? "mt-6 flex w-full max-w-[20rem] flex-col gap-y-1 text-[clamp(3rem,11vw,5.9rem)] leading-[1.06] tracking-[-0.03em] sm:max-w-[24rem] md:max-w-[30rem] lg:max-w-[32rem]"
: "mt-6 flex w-full max-w-[16rem] flex-col gap-y-1 text-[clamp(3.1rem,11vw,6.3rem)] leading-[0.94] tracking-[-0.06em] md:max-w-[22rem] lg:max-w-[32rem]" : "mt-6 flex w-full max-w-[20rem] flex-col gap-y-1 text-[clamp(3.1rem,11vw,6.3rem)] leading-[0.94] tracking-[-0.06em] sm:max-w-[24rem] md:max-w-[30rem] lg:max-w-[32rem]"
: "mt-4 flex w-full max-w-[16rem] flex-col gap-y-1 text-[clamp(2.8rem,10vw,4.9rem)] leading-[0.96] tracking-[-0.06em] md:max-w-[22rem] lg:max-w-[32rem]", : "mt-4 flex w-full max-w-[20rem] flex-col gap-y-1 text-[clamp(2.8rem,10vw,4.9rem)] leading-[0.96] tracking-[-0.06em] sm:max-w-[24rem] md:max-w-[28rem] lg:max-w-[32rem]",
className, className,
)} )}
> >
@@ -194,12 +200,24 @@ export function HeroTitle({
key={`${index}-${line.text}`} key={`${index}-${line.text}`}
className={cn( className={cn(
"hero-title-line", "hero-title-line",
toneClasses[line.tone ?? "default"],
lineAlignClasses[line.align ?? "center"], lineAlignClasses[line.align ?? "center"],
)} )}
> >
{line.segments?.length
? line.segments.map((segment, segmentIndex) => (
<span
key={`${index}-${segmentIndex}-${segment.text}`}
className={cn(toneClasses[segment.tone ?? "default"])}
>
{segment.text}
</span>
))
: (
<span className={cn(toneClasses[line.tone ?? "default"])}>
{line.text} {line.text}
</span> </span>
)}
</span>
))} ))}
</motion.h1> </motion.h1>
); );
+37 -4
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { motion } from "framer-motion"; import { motion, useAnimationControls, useReducedMotion } from "framer-motion";
import Image from "next/image"; import Image from "next/image";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -13,6 +13,7 @@ type SiteLogoProps = {
alt: string; alt: string;
priority?: boolean; priority?: boolean;
className?: string; className?: string;
replayKey?: number;
}; };
export function SiteLogo({ export function SiteLogo({
@@ -21,14 +22,46 @@ export function SiteLogo({
alt, alt,
priority = false, priority = false,
className, className,
replayKey = 0,
}: SiteLogoProps) { }: SiteLogoProps) {
const { theme, resolvedTheme } = useTheme(); const { theme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const controls = useAnimationControls();
const reducedMotion = useReducedMotion();
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
useEffect(() => {
if (reducedMotion) {
return;
}
controls.set({
opacity: 0,
x: -42,
scale: 0.88,
rotate: -360,
filter: "blur(8px)",
});
void controls.start({
opacity: 1,
x: 0,
scale: 1,
rotate: 0,
filter: "blur(0px)",
transition: {
x: { duration: 0.78, ease: [0.22, 1, 0.36, 1] },
opacity: { duration: 0.46 },
rotate: { duration: 0.56, ease: "easeOut" },
scale: { duration: 0.72, ease: [0.22, 1, 0.36, 1] },
filter: { duration: 0.42, ease: [0.22, 1, 0.36, 1] },
},
});
}, [controls, reducedMotion, replayKey]);
const activeTheme = theme === "system" ? resolvedTheme : theme; const activeTheme = theme === "system" ? resolvedTheme : theme;
const isDark = mounted && activeTheme === "dark"; const isDark = mounted && activeTheme === "dark";
const fallbackSrc = isDark ? "/logos/dark-primary.svg" : "/logos/light-primary.svg"; const fallbackSrc = isDark ? "/logos/dark-primary.svg" : "/logos/light-primary.svg";
@@ -37,11 +70,10 @@ export function SiteLogo({
return ( return (
<motion.span <motion.span
className="block [transform-style:preserve-3d]" className="block [transform-style:preserve-3d]"
initial={{ opacity: 0, x: -42, scale: 0.88, rotate: -360 }} initial={reducedMotion ? false : undefined}
animate={{ opacity: 1, x: 0, scale: 1, rotate: 0 }} animate={reducedMotion ? undefined : controls}
whileHover={{ scale: 1.05, y: -2, rotate: -2, filter: "drop-shadow(0 16px 28px rgba(15,23,42,0.14))" }} whileHover={{ scale: 1.05, y: -2, rotate: -2, filter: "drop-shadow(0 16px 28px rgba(15,23,42,0.14))" }}
whileTap={{ scale: 0.9, rotate: 6, y: 1, filter: "drop-shadow(0 8px 18px rgba(15,23,42,0.18))" }} whileTap={{ scale: 0.9, rotate: 6, y: 1, filter: "drop-shadow(0 8px 18px rgba(15,23,42,0.18))" }}
transition={{ x: { duration: 0.78, ease: [0.22, 1, 0.36, 1] }, opacity: { duration: 0.46 }, rotate: { duration: 0.56, ease: "easeOut" }, scale: { duration: 0.72, ease: [0.22, 1, 0.36, 1] } }}
> >
<Image <Image
src={src} src={src}
@@ -51,6 +83,7 @@ export function SiteLogo({
sizes="(min-width: 1280px) 360px, (min-width: 1024px) 320px, (min-width: 640px) 280px, 240px" sizes="(min-width: 1280px) 360px, (min-width: 1024px) 320px, (min-width: 640px) 280px, 240px"
className={cn( className={cn(
"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", "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",
mounted ? "opacity-100" : "opacity-0",
className, className,
)} )}
priority={priority} priority={priority}
+1 -1
View File
@@ -28,7 +28,7 @@
}, },
"homepage": { "homepage": {
"heroKicker": "مرحبااا، أنا محمد", "heroKicker": "مرحبااا، أنا محمد",
"heroTitle": "مطور واجهات ويب\nومصمم جرافيك", "heroTitle": "مطوّر واجهات ويب\nومصمّم جرافيك",
"heroText": "بشتغل على حلول رقمية لمشاكل حقيقية\nبتطلع ممتازة وبتشتغل بكفاءة.", "heroText": "بشتغل على حلول رقمية لمشاكل حقيقية\nبتطلع ممتازة وبتشتغل بكفاءة.",
"servicesEyebrow": "شو فيني قدّم", "servicesEyebrow": "شو فيني قدّم",
"servicesTitle": "حلول رقمية مصممة حسب شغلك، مو قوالب جاهزة.", "servicesTitle": "حلول رقمية مصممة حسب شغلك، مو قوالب جاهزة.",