Resizable Navbar
A navbar that changes width on scroll, responsive and animated.
Note: Scroll gently and watch the navbar resize.
<script setup lang="ts">
import { ref } from "vue";
import MobileNavHeader from "./mobile-nav-header.vue";
import MobileNavMenu from "./mobile-nav-menu.vue";
import MobileNavToggle from "./mobile-nav-toggle.vue";
import MobileNav from "./mobile-nav.vue";
import NavBody from "./nav-body.vue";
import NavItems from "./nav-items.vue";
import NavbarButton from "./navbar-button.vue";
import NavbarLogo from "./navbar-logo.vue";
import Navbar from "./navbar.vue";
const isMenuOpen = ref(false);
function toggleMenu() {
isMenuOpen.value = !isMenuOpen.value;
}
function closeMenu() {
isMenuOpen.value = false;
}
const navItems = [
{ name: "Home", link: "/" },
{ name: "Features", link: "/features" },
{ name: "Pricing", link: "/pricing" },
{ name: "About", link: "/about" },
{ name: "Contact", link: "/contact" },
];
const boxes = [
{
id: 1,
title: "The",
width: "md:col-span-1",
height: "h-60",
bg: "bg-gray-100",
},
{
id: 2,
title: "First",
width: "md:col-span-2",
height: "h-60",
bg: "bg-gray-100",
},
{
id: 3,
title: "Rule",
width: "md:col-span-1",
height: "h-60",
bg: "bg-gray-100",
},
{
id: 4,
title: "Of",
width: "md:col-span-3",
height: "h-60",
bg: "bg-gray-100",
},
{
id: 5,
title: "F",
width: "md:col-span-1",
height: "h-60",
bg: "bg-gray-100",
},
{
id: 6,
title: "Club",
width: "md:col-span-2",
height: "h-60",
bg: "bg-gray-100",
},
{
id: 7,
title: "Is",
width: "md:col-span-2",
height: "h-60",
bg: "bg-gray-100",
},
{
id: 8,
title: "You",
width: "md:col-span-1",
height: "h-60",
bg: "bg-gray-100",
},
{
id: 9,
title: "Do NOT TALK about",
width: "md:col-span-2",
height: "h-60",
bg: "bg-gray-100",
},
{
id: 10,
title: "F Club",
width: "md:col-span-1",
height: "h-60",
bg: "bg-gray-100",
},
];
</script>
<template>
<div class="grid place-items-center min-h-screen w-full">
<Navbar>
<template #default="{ visible }">
<NavBody :visible="visible">
<NavbarLogo />
<NavItems :items="navItems" @item-click="closeMenu" />
<NavbarButton to="/signup" variant="primary"> Get Started </NavbarButton>
</NavBody>
<MobileNav :visible="visible">
<MobileNavHeader>
<NavbarLogo />
<MobileNavToggle :is-open="isMenuOpen" @click="toggleMenu" />
</MobileNavHeader>
<MobileNavMenu :is-open="isMenuOpen" @close="closeMenu">
<a
v-for="(item, idx) in navItems"
:key="`mobile-link-${idx}`"
:href="item.link"
class="w-full px-4 py-2 text-neutral-600 hover:bg-gray-100 rounded-md"
@click="closeMenu"
>
{{ item.name }}
</a>
<NavbarButton to="/signup" variant="dark" class="w-full mt-4" @click="closeMenu">
Get Started
</NavbarButton>
</MobileNavMenu>
</MobileNav>
</template>
</Navbar>
<div class="container mx-auto px-8 pt-24">
<h1 class="mb-4 text-center text-3xl font-bold">
Check the navbar at the top of the container
</h1>
<p class="mb-10 text-center text-sm text-zinc-500">
For demo purpose we have kept the position as
<span class="font-medium">Sticky</span>. Keep in mind that this component is
<span class="font-medium">fixed</span> and will not move when scrolling.
</p>
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
<div
v-for="box in boxes"
:key="box.id"
class="flex items-center justify-center rounded-lg p-4 shadow-sm"
:class="[box.width, box.height, box.bg]"
>
<h2 class="text-xl font-medium">
{{ box.title }}
</h2>
</div>
</div>
</div>
</div>
</template>Installation
Install the following dependencies
bash
pnpm add @lucide/vueInstallation
Copy and paste the following code into your project:
vue
<script setup lang="ts">
import type { Slot } from "vue";
import { onMounted, onUnmounted, ref } from "vue";
const props = defineProps<{
className?: string;
}>();
const navbarRef = ref(null);
const visible = ref(false);
function handleScroll() {
if (window.scrollY > 100) {
visible.value = true;
} else {
visible.value = false;
}
}
onMounted(() => {
window.addEventListener("scroll", handleScroll);
});
onUnmounted(() => {
window.removeEventListener("scroll", handleScroll);
});
function provide(slot: Slot) {
if (!slot) return;
return {
visible: visible.value,
};
}
</script>
<template>
<div
ref="navbarRef"
class="sticky inset-x-0 top-0 md:top-10 z-50 w-full"
:class="props.className"
>
<div class="w-full grid place-items-center pt-10">
<div class="max-w-4xl w-full">
<slot v-bind="provide($slots?.default)" />
</div>
</div>
</div>
</template>vue
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{
href?: string;
to?: string;
variant?: "primary" | "secondary" | "dark" | "gradient";
className?: string;
}>();
const baseStyles =
"px-4 py-2 rounded-md bg-white button bg-white text-black text-sm font-bold relative cursor-pointer hover:-translate-y-0.5 transition duration-200 inline-block text-center";
const variantStyles: Record<string, string> = {
primary:
"shadow-[0_0_24px_rgba(34,_42,_53,_0.06),_0_1px_1px_rgba(0,_0,_0,_0.05),_0_0_0_1px_rgba(34,_42,_53,_0.04),_0_0_4px_rgba(34,_42,_53,_0.08),_0_16px_68px_rgba(47,_48,_55,_0.05),_0_1px_0_rgba(255,_255,_255,_0.1)_inset]",
secondary: "bg-transparent shadow-none dark:text-white",
dark: "bg-black text-white shadow-[0_0_24px_rgba(34,_42,_53,_0.06),_0_1px_1px_rgba(0,_0,_0,_0.05),_0_0_0_1px_rgba(34,_42,_53,_0.04),_0_0_4px_rgba(34,_42,_53,_0.08),_0_16px_68px_rgba(47,_48,_55,_0.05),_0_1px_0_rgba(255,_255,_255,_0.1)_inset]",
gradient:
"bg-gradient-to-b from-blue-500 to-blue-700 text-white shadow-[0px_2px_0px_0px_rgba(255,255,255,0.3)_inset]",
};
const classes = computed(() => {
return [baseStyles, props.variant && variantStyles[props.variant], props.className]
.filter(Boolean)
.join(" ");
});
const isRouterLink = computed(() => props.to !== undefined);
</script>
<template>
<a v-if="isRouterLink" :href="to ?? ''" :class="classes">
<slot />
</a>
<a v-else :href="href || '#'" :class="classes">
<slot />
</a>
</template>vue
<script setup lang="ts"></script>
<template>
<a
href="/"
class="relative z-20 mr-4 flex items-center space-x-2 px-2 py-1 text-sm font-normal text-black"
>
<img src="https://ui.selemon.dev/icon.png" alt="logo" width="30" height="30" />
<span class="font-medium text-black dark:text-white">Spark UI</span>
</a>
</template>vue
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{
visible?: boolean;
className?: string;
}>();
const navBodyStyles = computed(() => {
return {
backdropFilter: props.visible ? "blur(10px)" : "none",
boxShadow: props.visible
? "0 0 24px rgba(34, 42, 53, 0.06), 0 1px 1px rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(34, 42, 53, 0.04), 0 0 4px rgba(34, 42, 53, 0.08), 0 16px 68px rgba(47, 48, 55, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1) inset"
: "none",
width: props.visible ? "40%" : "100%",
transform: props.visible ? "translateY(20px)" : "translateY(0)",
minWidth: "800px",
transition: "all 0.3s",
};
});
const navBodyClasses = computed(() => {
return [
"relative z-[60] mx-auto hidden w-full max-w-7xl flex-row items-center justify-between self-start rounded-full bg-transparent px-4 py-2 lg:flex dark:bg-transparent",
props.visible && "bg-white/80 dark:bg-neutral-950/80",
props.className,
]
.filter(Boolean)
.join(" ");
});
</script>
<template>
<div :class="navBodyClasses" :style="navBodyStyles">
<slot />
</div>
</template>vue
<script setup lang="ts">
import { ref } from "vue";
defineProps<{
items: Array<{ name: string; link: string }>;
className?: string;
}>();
const emits = defineEmits(["itemClick"]);
const hovered = ref<number | null>(null);
function handleItemHover(idx: number) {
hovered.value = idx;
}
function clearHover() {
hovered.value = null;
}
function handleClick() {
emits("itemClick");
}
</script>
<template>
<div
class="absolute inset-0 hidden flex-1 flex-row items-center justify-center space-x-2 text-sm font-medium text-zinc-600 transition duration-200 hover:text-zinc-800 lg:flex lg:space-x-2"
:class="className"
@mouseleave="clearHover"
>
<a
v-for="(item, idx) in items"
:key="`link-${idx}`"
:href="item?.link"
class="relative px-4 py-2 text-neutral-600"
@mouseenter="handleItemHover(idx)"
@click="handleClick"
>
<transition name="fade">
<div
v-if="hovered === idx"
class="absolute inset-0 h-full w-full rounded-full bg-gray-100"
/>
</transition>
<span class="relative z-20 dark:text-white">{{ item?.name }}</span>
</a>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>vue
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{
visible: boolean;
className?: string;
}>();
const mobileNavStyles = computed(() => {
return {
backdropFilter: props.visible ? "blur(10px)" : "none",
boxShadow: props.visible
? "0 0 24px rgba(34, 42, 53, 0.06), 0 1px 1px rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(34, 42, 53, 0.04), 0 0 4px rgba(34, 42, 53, 0.08), 0 16px 68px rgba(47, 48, 55, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1) inset"
: "none",
width: props.visible ? "90%" : "100%",
paddingRight: props.visible ? "12px" : "0px",
paddingLeft: props.visible ? "12px" : "0px",
borderRadius: props.visible ? "4px" : "2rem",
transform: props.visible ? "translateY(20px)" : "translateY(0)",
transition: "all 0.3s",
};
});
const mobileNavClasses = computed(() => {
return [
"relative z-50 mx-auto flex w-full max-w-[calc(100vw-2rem)] flex-col items-center justify-between bg-transparent px-0 py-2 lg:hidden",
props.visible && "bg-white/80 dark:bg-neutral-950/80",
props.className,
]
.filter(Boolean)
.join(" ");
});
</script>
<template>
<div :class="mobileNavClasses" :style="mobileNavStyles">
<slot />
</div>
</template>vue
<script setup lang="ts">
const props = defineProps<{
className?: string;
}>();
</script>
<template>
<div class="flex w-full flex-row items-center justify-between" :class="[props.className]">
<slot />
</div>
</template>vue
<script setup lang="ts">
defineProps<{
isOpen: boolean;
className?: string;
}>();
</script>
<template>
<Transition name="menu">
<div
v-if="isOpen"
class="absolute inset-x-0 top-16 z-50 flex w-full flex-col items-start justify-start gap-4 rounded-lg bg-white px-4 py-8 shadow-[0_0_24px_rgba(34,_42,_53,_0.06),_0_1px_1px_rgba(0,_0,_0,_0.05),_0_0_0_1px_rgba(34,_42,_53,_0.04),_0_0_4px_rgba(34,_42,_53,_0.08),_0_16px_68px_rgba(47,_48,_55,_0.05),_0_1px_0_rgba(255,_255,_255,_0.1)_inset] dark:bg-neutral-950"
:class="className"
>
<slot />
</div>
</Transition>
</template>
<style scoped>
.menu-enter-active,
.menu-leave-active {
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.menu-enter-from,
.menu-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>vue
<script setup lang="ts">
import { Menu, X } from "@lucide/vue";
defineProps<{
isOpen: boolean;
}>();
const emit = defineEmits(["click"]);
function handleClick() {
emit("click");
}
</script>
<template>
<div class="cursor-pointer" @click="handleClick">
<X v-if="isOpen" class="text-black dark:text-white" />
<Menu v-else class="text-black dark:text-white" />
</div>
</template>Props
Navbar
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes to apply to the navbar |
NavBody
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes to apply to the nav body |
visible | boolean | false | Controls the visibility state of the nav body |
NavItems
| Prop | Type | Default | Description |
|---|---|---|---|
items | Array<{ name: string, link: string }> | - | Array of navigation items with name and link |
className | string | - | Additional CSS classes to apply to the nav items |
MobileNav
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes to apply to the mobile nav |
visible | boolean | false | Controls the visibility state of the mobile nav |
MobileNavHeader
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes to apply to the mobile nav header |
MobileNavMenu
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes to apply to the mobile nav menu |
isOpen | boolean | - | Controls whether the mobile menu is open |
MobileNavToggle
| Prop | Type | Default | Description |
|---|---|---|---|
isOpen | boolean | - | Controls whether the mobile menu is open |
onClick | () => void | - | Callback function when the toggle is clicked |
NavbarButton
| Prop | Type | Default | Description |
|---|---|---|---|
href | string | - | URL for the button link |
className | string | - | Additional CSS classes to apply to the button |
variant | "primary" | "secondary" | "dark" | "gradient" | "primary" | Visual style variant of the button |