Skip to content

Commit c878f3f

Browse files
committed
feat: add landing page navbar with custom logo
- Install @efferd/header-2 (sticky scroll-aware header + mobile nav) - Replace default logo with project SVG mark (#a0ec06 lime green) - Wire Header into landing page layout
1 parent 862eb7e commit c878f3f

6 files changed

Lines changed: 242 additions & 7 deletions

File tree

src/app/page.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1+
import { Header } from "@/components/header";
12
import { Demo } from "@/components/demo";
23

34
export default function Page() {
45
return (
5-
<div className="flex min-h-svh items-center justify-center p-6">
6-
<div className="text-center">
7-
<h1 className="text-2xl font-semibold">Next.js + Supabase + Creem</h1>
8-
<p className="mt-2 text-sm text-muted-foreground">Starter template — coming soon</p>
6+
<>
7+
<Header />
8+
<div className="flex min-h-svh items-center justify-center p-6">
9+
<div className="text-center">
10+
<h1 className="text-2xl font-semibold">Next.js + Supabase + Creem</h1>
11+
<p className="mt-2 text-sm text-muted-foreground">Starter template — coming soon</p>
12+
</div>
13+
<Demo />
914
</div>
10-
<Demo />
11-
</div>
12-
)
15+
</>
16+
);
1317
}

src/components/header.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"use client";
2+
import { cn } from "@/lib/utils";
3+
import { Logo } from "@/components/logo";
4+
import { useScroll } from "@/hooks/use-scroll";
5+
import { Button } from "@/components/ui/button";
6+
import { MobileNav } from "@/components/mobile-nav";
7+
8+
export const navLinks = [
9+
{
10+
label: "Features",
11+
href: "#",
12+
},
13+
{
14+
label: "Pricing",
15+
href: "#",
16+
},
17+
{
18+
label: "About",
19+
href: "#",
20+
},
21+
];
22+
23+
export function Header() {
24+
const scrolled = useScroll(10);
25+
26+
return (
27+
<header
28+
className={cn(
29+
"sticky top-0 z-50 mx-auto w-full max-w-4xl border-transparent border-b md:rounded-md md:border md:transition-all md:ease-out",
30+
{
31+
"border-border bg-background/95 backdrop-blur-sm supports-backdrop-filter:bg-background/50 md:top-2 md:max-w-3xl md:shadow":
32+
scrolled,
33+
}
34+
)}
35+
>
36+
<nav
37+
className={cn(
38+
"flex h-14 w-full items-center justify-between px-4 md:h-12 md:transition-all md:ease-out",
39+
{
40+
"md:px-2": scrolled,
41+
}
42+
)}
43+
>
44+
<a
45+
className="rounded-md p-2 hover:bg-muted dark:hover:bg-muted/50"
46+
href="#"
47+
>
48+
<Logo className="h-4" />
49+
</a>
50+
<div className="hidden items-center gap-2 md:flex">
51+
<div>
52+
{navLinks.map((link) => (
53+
<Button key={link.label} size="sm" variant="ghost" render={<a href={link.href} />} nativeButton={false}>{link.label}</Button>
54+
))}
55+
</div>
56+
<Button size="sm" variant="outline">
57+
Sign In
58+
</Button>
59+
<Button size="sm">Get Started</Button>
60+
</div>
61+
<MobileNav />
62+
</nav>
63+
</header>
64+
);
65+
}

src/components/logo.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type React from "react";
2+
3+
export const LogoIcon = (props: React.ComponentProps<"svg">) => (
4+
<svg
5+
xmlns="http://www.w3.org/2000/svg"
6+
viewBox="0 0 94.79 108.66"
7+
{...props}
8+
>
9+
<path
10+
fill="#a0ec06"
11+
d="M55.52,18.95s.09-5.11,5.82-5.11h29.29l3.56-13.84h-26.66c-3.42,0-6.19,2.77-6.19,6.19,0,5.11-5.82,5.11-5.82,5.11h-27.57l-.19.08c-9.7,0-18.23,4.98-23.2,12.51v1.25h44.77c3.42,0,6.19-2.77,6.19-6.19Z"
12+
/>
13+
<path
14+
fill="#a0ec06"
15+
d="M43.79,46.02c3.42,0,6.19-2.77,6.19-6.19,0,0,.09-5.11,5.82-5.11h29.45l3.56-13.84h-26.83c-3.42,0-6.19,2.77-6.19,6.19,0,5.11-5.82,5.11-5.82,5.11H.88c-.57,2.22-.88,4.55-.88,6.96s.3,4.68.86,6.88h42.93Z"
16+
/>
17+
<path
18+
fill="#a0ec06"
19+
d="M73.95,42.56c-1.72-.52-3.5-.79-5.3-.79h-11.36c-3.4,0-6.29,2.72-6.26,6.12.05,5.18-5.82,5.18-5.82,5.18H3.75c4.81,8.27,13.76,13.84,24.01,13.84h11.26c3.42,0,6.19-2.77,6.19-6.19,0,0,.09-5.11,5.82-5.11h40.09c-3.07-7.35-10.88-11.14-17.17-13.04Z"
20+
/>
21+
<path
22+
fill="#a0ec06"
23+
d="M94.05,62.65h-42.5c-3.42,0-6.19,2.77-6.19,6.19,0,5.11-5.82,5.11-5.82,5.11H10.11l-3.69,13.84h26.93c3.42,0,6.19-2.77,6.19-6.19,0,0,.09-5.11,5.82-5.11h48.48c.62-2.35.95-4.82.95-7.36,0-2.23-.26-4.39-.74-6.48Z"
24+
/>
25+
<path
26+
fill="#a0ec06"
27+
d="M39.3,89.72c0,5.11-5.82,5.11-5.82,5.11H4.55l-3.69,13.84h26.43c3.42,0,6.19-2.77,6.19-6.19,0,0,.09-5.11,5.82-5.11h32.45c7.76-1.58,14.38-6.29,18.51-12.74v-1.09h-44.77c-3.42,0-6.19,2.77-6.19,6.19Z"
28+
/>
29+
</svg>
30+
);
31+
32+
export const Logo = LogoIcon;

src/components/mobile-nav.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { cn } from "@/lib/utils";
2+
import React from "react";
3+
import { Portal, PortalBackdrop } from "@/components/ui/portal";
4+
import { Button } from "@/components/ui/button";
5+
import { navLinks } from "@/components/header";
6+
import { IconX, IconMenu2 } from "@tabler/icons-react";
7+
8+
export function MobileNav() {
9+
const [open, setOpen] = React.useState(false);
10+
11+
return (
12+
<div className="md:hidden">
13+
<Button
14+
aria-controls="mobile-menu"
15+
aria-expanded={open}
16+
aria-label="Toggle menu"
17+
className="md:hidden"
18+
onClick={() => setOpen(!open)}
19+
size="icon"
20+
variant="outline"
21+
>
22+
{open ? (
23+
<IconX className="size-4.5" />
24+
) : (
25+
<IconMenu2 className="size-4.5" />
26+
)}
27+
</Button>
28+
{open && (
29+
<Portal className="top-14" id="mobile-menu">
30+
<PortalBackdrop />
31+
<div
32+
className={cn(
33+
"data-[slot=open]:zoom-in-97 ease-out data-[slot=open]:animate-in",
34+
"size-full p-4"
35+
)}
36+
data-slot={open ? "open" : "closed"}
37+
>
38+
<div className="grid gap-y-2">
39+
{navLinks.map((link) => (
40+
<Button className="justify-start" key={link.label} variant="ghost" render={<a href={link.href} />} nativeButton={false}>{link.label}</Button>
41+
))}
42+
</div>
43+
<div className="mt-12 flex flex-col gap-2">
44+
<Button className="w-full" variant="outline">
45+
Sign In
46+
</Button>
47+
<Button className="w-full">Get Started</Button>
48+
</div>
49+
</div>
50+
</Portal>
51+
)}
52+
</div>
53+
);
54+
}

src/components/ui/portal.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { cn } from "@/lib/utils";
2+
import React from "react";
3+
import { createPortal } from "react-dom";
4+
5+
function Portal({ className, ...props }: React.ComponentProps<"div">) {
6+
const [mounted, setMounted] = React.useState(false);
7+
8+
React.useEffect(() => {
9+
setMounted(true);
10+
11+
const originalStyle = window.getComputedStyle(document.body).overflow;
12+
const scrollbarWidth =
13+
window.innerWidth - document.documentElement.clientWidth;
14+
const originalPaddingRight = document.body.style.paddingRight;
15+
16+
document.body.style.overflow = "hidden";
17+
if (scrollbarWidth > 0) {
18+
document.body.style.paddingRight = `${scrollbarWidth}px`;
19+
}
20+
21+
return () => {
22+
document.body.style.overflow = originalStyle;
23+
document.body.style.paddingRight = originalPaddingRight;
24+
};
25+
}, []);
26+
27+
if (!mounted) {
28+
return null;
29+
}
30+
31+
return createPortal(
32+
<div
33+
className={cn("fixed inset-0 isolate z-40 flex flex-col", className)}
34+
{...props}
35+
/>,
36+
document.body
37+
);
38+
}
39+
40+
function PortalBackdrop({ className, ...props }: React.ComponentProps<"div">) {
41+
return (
42+
<div
43+
className={cn(
44+
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 -z-1 bg-background/95 backdrop-blur-sm duration-500 data-[state=closed]:animate-out data-[state=open]:animate-in supports-backdrop-filter:bg-background/60",
45+
className
46+
)}
47+
{...props}
48+
/>
49+
);
50+
}
51+
52+
export { Portal, PortalBackdrop };

src/hooks/use-scroll.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"use client";
2+
import { useEffect, useState } from "react";
3+
4+
export function useScroll(downThreshold: number, upThreshold?: number) {
5+
const [scrolled, setScrolled] = useState(false);
6+
const scrollUpThreshold = upThreshold ?? downThreshold / 2;
7+
8+
useEffect(() => {
9+
const handleScroll = () => {
10+
const y = window.scrollY;
11+
// Hysteresis: different thresholds for up/down to prevent flickering
12+
setScrolled((prev) => {
13+
if (prev) {
14+
// Currently scrolled - only unscroll when below lower threshold
15+
return y > scrollUpThreshold;
16+
}
17+
// Currently not scrolled - only scroll when above higher threshold
18+
return y > downThreshold;
19+
});
20+
};
21+
22+
window.addEventListener("scroll", handleScroll, { passive: true });
23+
handleScroll();
24+
return () => window.removeEventListener("scroll", handleScroll);
25+
}, [downThreshold, scrollUpThreshold]);
26+
27+
return scrolled;
28+
}

0 commit comments

Comments
 (0)