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 Navbar from "./Navbar.vue";
import NavBody from "./NavBody.vue";
import NavItems from "./NavItems.vue";
import MobileNav from "./MobileNav.vue";
import NavbarLogo from "./NavbarLogo.vue";
import NavbarButton from "./NavbarButton.vue";
import MobileNavHeader from "./MobileNavHeader.vue";
import MobileNavToggle from "./MobileNavToggle.vue";
import MobileNavMenu from "./MobileNav.vue";
import { ref } from 'vue';
const isMenuOpen = ref(false);
const toggleMenu = () => {
isMenuOpen.value = !isMenuOpen.value;
};
const 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" @click="closeMenu"
class="w-full px-4 py-2 text-neutral-600 hover:bg-gray-100 rounded-md">
{{ 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="[
box.width,
box.height,
box.bg,
'flex items-center justify-center rounded-lg p-4 shadow-sm'
]">
<h2 class="text-xl font-medium">{{ box.title }}</h2>
</div>
</div>
</div>
</div>
</template>
Installation
Install the following dependencies
bash
pnpm add lucide-vue-next@latest
Installation
Copy and paste the following code into your project:
vue
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import type { Slot } from 'vue';
const props = defineProps<{
className?: string
}>();
const navbarRef = ref(null);
const visible = ref(false);
const handleScroll = () => {
if (window.scrollY > 100) {
visible.value = true;
} else {
visible.value = false;
}
};
onMounted(() => {
window.addEventListener('scroll', handleScroll);
});
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll);
});
const 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)"></slot>
</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></slot>
</a>
<a v-else :href="href || '#'" :class="classes">
<slot></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://spark-ui.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></slot>
</div>
</template>
vue
<script setup lang="ts">
import { ref } from 'vue';
defineProps<{
items: Array<{ name: string; link: string }>;
className?: string;
}>()
const hovered = ref<number | null>(null);
const emits = defineEmits(['itemClick']);
const handleItemHover = (idx: number) => {
hovered.value = idx;
};
const clearHover = () => {
hovered.value = null;
};
const handleClick = () => {
emits('itemClick');
};
</script>
<template>
<div
@mouseleave="clearHover"
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"
>
<a
v-for="(item, idx) in items"
:key="`link-${idx}`"
:href="item?.link"
@mouseenter="handleItemHover(idx)"
@click="handleClick"
class="relative px-4 py-2 text-neutral-600"
>
<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></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', props.className]">
<slot></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></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-next';
defineProps<{
isOpen: boolean;
}>();
const emit = defineEmits(['click']);
const handleClick = () => {
emit('click');
};
</script>
<template>
<div @click="handleClick" class="cursor-pointer">
<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 |