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 "@/components/spark-ui/resizable-navbar/mobile-nav-header.vue";
import MobileNavMenu from "@/components/spark-ui/resizable-navbar/mobile-nav-menu.vue";
import MobileNavToggle from "@/components/spark-ui/resizable-navbar/mobile-nav-toggle.vue";
import MobileNav from "@/components/spark-ui/resizable-navbar/mobile-nav.vue";
import NavBody from "@/components/spark-ui/resizable-navbar/nav-body.vue";
import NavItems from "@/components/spark-ui/resizable-navbar/nav-items.vue";
import NavbarButton from "@/components/spark-ui/resizable-navbar/navbar-button.vue";
import NavbarLogo from "@/components/spark-ui/resizable-navbar/navbar-logo.vue";
import Navbar from "@/components/spark-ui/resizable-navbar/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 dark:text-neutral-300 hover:bg-gray-100 dark:hover:bg-neutral-800 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 dark:text-zinc-400">
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
pnpm add @lucide/vueCopy the component files below into src/components/spark-ui/resizable-navbar/. Utility imports use @/lib/utils.
<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(() => {
handleScroll();
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-10 z-40 w-full" :class="props.className">
<slot v-bind="provide($slots?.default)" />
</div>
</template><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(" ");
});
</script>
<template>
<a :href="to ?? (href || '#')" :class="classes">
<slot />
</a>
</template><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 dark:text-white"
>
<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><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 ? "min(800px, 90%)" : "100%",
transform: props.visible ? "translateY(20px)" : "translateY(0)",
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><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><script setup lang="ts">
import { computed, provide, useId } from "vue";
provide("spark-mobile-nav-id", useId());
const props = defineProps({
className: String,
visible: Boolean,
});
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><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><script setup lang="ts">
import { inject } from "vue";
const menuId = inject<string | undefined>("spark-mobile-nav-id", undefined);
const emit = defineEmits<{ close: [] }>();
function closeMenu() {
emit("close");
if (menuId) document.getElementById(`${menuId}-toggle`)?.focus();
}
defineProps({
className: String,
isOpen: Boolean,
});
</script>
<template>
<Transition name="menu">
<div
v-if="isOpen"
:id="menuId"
@keydown.escape.stop.prevent="closeMenu"
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><script setup lang="ts">
import { Menu, X } from "@lucide/vue";
import { inject } from "vue";
const menuId = inject<string | undefined>("spark-mobile-nav-id", undefined);
defineProps({
isOpen: Boolean,
});
const emit = defineEmits(["click"]);
function handleClick() {
emit("click");
}
</script>
<template>
<button
type="button"
class="cursor-pointer"
:id="menuId ? `${menuId}-toggle` : undefined"
:aria-controls="isOpen ? menuId : undefined"
:aria-expanded="isOpen"
:aria-label="isOpen ? 'Close navigation menu' : 'Open navigation menu'"
@click="handleClick"
@keydown.escape.stop.prevent="isOpen && handleClick()"
>
<X v-if="isOpen" class="text-black dark:text-white" />
<Menu v-else class="text-black dark:text-white" />
</button>
</template>Behavior β
Keep MobileNavToggle and MobileNavMenu inside the same MobileNav so their generated accessible IDs are associated. Bind both isOpen props to the same state; handle the toggleβs click event and the menuβs close event to update it. Escape closes the open menu and returns focus to its toggle. The navbar measures the initial scroll position on mount. NavbarButton uses native anchors for both href and to; to takes precedence and does not require Vue Router.
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 |
MobileNavMenu emits close on Escape. Handle it with @close="isOpen = false".
MobileNavToggle β
| Prop | Type | Default | Description |
|---|---|---|---|
isOpen | boolean | - | Controls whether the mobile menu is open |
click | event | - | Emitted when the toggle is activated |
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 |