Layout

Nav

Responsive navigation bar with logo, links, and mobile hamburger menu. Includes theme toggle by default.

Props

PropTypeDefaultDescription
logoSrcstringVCSI logoLogo image source
logoAltstring'VCSI Logo'Logo alt text
linksNavLink[]—Array of { href, label }
showThemeTogglebooleantrueShow dark mode toggle

Import

import { Nav } from '@the-vcsi/scrolly-kit';

Usage

<script>
  import { Nav } from '@the-vcsi/scrolly-kit';
</script>

<Nav
  links={[
    { href: '/about', label: 'About' },
    { href: '/stories', label: 'Stories' }
  ]}
/>

Full Source

💡 Components rely on --vcsi-* tokens from tokens.css. You'd need to either need to @import '@the-vcsi/scrolly-kit/styles/tokens.css'; to access the CSS variables or define equivalent variables in your app.css. We also are using types here to provide hints when users are using the components in their project.

<script lang="ts">
import ThemeToggle from './ThemeToggle.svelte';
import NavMenu from './NavMenu.svelte';
import { Menu as MenuIcon } from "@lucide/svelte";

interface NavLink {
	href: string;
	label: string;
	external?: boolean;
}

interface Props {
	/** Forces light or dark theme, ignoring global mode */
	theme?: 'light' | 'dark';
	/** Logo image source */
	logoSrc?: string;
	/** Logo alt text */
	logoAlt?: string;
	/** Home link href (default: '/') */
	homeHref?: string;
	/** Navigation links to display */
	links?: NavLink[];
	/** GitHub repository URL (shows icon if provided) */
	githubUrl?: string;
	/** Whether to show the theme toggle (default: true) */
	showThemeToggle?: boolean;
	/** Header position CSS value */
	position?: 'fixed' | 'sticky' | 'relative';
}

let {
	theme,
	logoSrc = '/waxSealSmall.png',
	logoAlt = 'Site logo',
	homeHref = '/',
	links = [
		{ href: '/about', label: 'About' }
	],
	githubUrl = 'https://github.com/Vermont-Complex-Systems/vcsi-starter',
	showThemeToggle = true,
	position = 'fixed'
}: Props = $props();

let isMenuOpen = $state(false);
let menuButtonRef: HTMLButtonElement | undefined;
let scrollY = $state(0);
let isScrolled = $derived(scrollY > 0);

function closeMenu(skipFocus: boolean | string = false) {
	isMenuOpen = false;
	if (!skipFocus) menuButtonRef?.focus();
}
</script>

<svelte:window bind:scrollY />

<header
	class={[
		'nav-header',
		isScrolled && 'scrolled',
		theme === 'light' && 'theme-light',
		theme === 'dark' && 'theme-dark'
	]}
	style:--header-position={position}
>
	<div class="header-inner">
		<div class="header-left">
			<a href={homeHref} class="title-link">
				<img src={logoSrc} alt={logoAlt} class="site-logo"/>
			</a>
		</div>

		<div class="header-right">
			{#each links as link}
				<a
					href={link.href}
					class="nav-link"
					target={link.external ? '_blank' : undefined}
					rel={link.external ? 'noopener noreferrer' : undefined}
				>
					{link.label}
				</a>
			{/each}

			{#if showThemeToggle}
				<ThemeToggle />
			{/if}

			{#if githubUrl}
				<a href={githubUrl} target="_blank" rel="noopener noreferrer" class="github-button" aria-label="View on GitHub">
					<svg viewBox="0 0 16 16" width="20" height="20" aria-hidden="true">
						<path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
					</svg>
				</a>
			{/if}

			<button
				onclick={() => isMenuOpen = !isMenuOpen}
				bind:this={menuButtonRef}
				class="icon-button mobile-menu-button"
			>
				<MenuIcon size={28} />
				<span class="sr-only">Open menu</span>
			</button>
		</div>
	</div>
</header>

<NavMenu visible={isMenuOpen} close={closeMenu} {links} />

<style>
.nav-header {
	position: var(--header-position, fixed);
	top: 0;
	left: 0;
	width: 100%;
	background: var(--vcsi-nav-bg);
	color: var(--vcsi-nav-fg);
	border-bottom: 2px solid transparent;
	z-index: 100;
	transition: border-color var(--vcsi-transition-base);
}

.nav-header.scrolled {
	border-bottom-color: var(--vcsi-border);
}

/* theme="light" - forces light theme, ignores global dark mode */
.nav-header.theme-light {
	background: var(--vcsi-bg-light);
	color: var(--vcsi-fg-light);
	--nav-fg: var(--vcsi-fg-light);
	--nav-hover: var(--vcsi-hover-light);
	--nav-border: var(--vcsi-border-light);
}

/* theme="dark" - forces dark theme, ignores global dark mode */
.nav-header.theme-dark {
	background: var(--vcsi-bg-dark);
	color: var(--vcsi-fg-dark);
	--nav-fg: var(--vcsi-fg-dark);
	--nav-hover: var(--vcsi-hover-dark);
	--nav-border: var(--vcsi-border-dark);
}

/* Default theming - follows global mode */
.nav-header:not(.theme-light):not(.theme-dark) {
	--nav-fg: var(--vcsi-nav-fg);
	--nav-hover: var(--vcsi-hover);
	--nav-border: var(--vcsi-border);
}

/* Inner container aligns nav content with page */
.header-inner {
	width: 100%;
	max-width: var(--vcsi-page-max-width);
	margin-inline: auto;
	padding-inline: var(--vcsi-page-inline-padding);
	display: flex;
	align-items: center;
	justify-content: space-between;
	min-height: var(--vcsi-nav-height);
}

.header-left,
.header-right {
	display: flex;
	align-items: center;
}

.header-left {
	margin-left: -0.5rem;
	margin-top: 0.1rem;
}

.title-link {
	text-decoration: none;
	color: inherit;
	transition: transform var(--vcsi-transition-base);
}

.title-link:hover {
	transform: translateY(-0.125rem);
}

.site-logo {
	height: 5rem;
	width: auto;
	display: block;
	object-fit: contain;
	object-position: center;
}

.nav-link {
	padding: var(--vcsi-space-sm) 0.75rem;
	font-family: var(--vcsi-font-sans);
	font-size: 0.9rem;
	font-weight: 500;
	color: var(--nav-fg, var(--vcsi-fg));
	text-decoration: none;
	background: transparent;
	border: none;
	border-radius: var(--vcsi-radius-md);
	transition: all var(--vcsi-transition-base);
}

.nav-link:hover {
	background: var(--nav-hover, var(--vcsi-hover));
}

.icon-button {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 2.5rem;
	height: 2.5rem;
	border-radius: var(--vcsi-radius-md);
	background: transparent;
	color: var(--nav-fg, var(--vcsi-fg));
	border: none;
	cursor: pointer;
	transition: all var(--vcsi-transition-base);
}

.icon-button:hover {
	transform: rotate(var(--right-tilt, 0)) scale(1.05);
	background: var(--nav-hover, var(--vcsi-hover));
}

.mobile-menu-button {
	display: none;
}

.github-button {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 2.25rem;
	height: 2.25rem;
	border-radius: var(--vcsi-radius-md);
	text-decoration: none;
	color: var(--nav-fg, var(--vcsi-fg));
	transition: all var(--vcsi-transition-base);
}

.github-button:hover {
	transform: scale(1.1);
	background: var(--nav-hover, var(--vcsi-hover));
}

.sr-only {
	position: absolute;
	width: 1px;
	height: 1px;
	padding: 0;
	margin: -1px;
	overflow: hidden;
	clip: rect(0, 0, 0, 0);
	white-space: nowrap;
	border: 0;
}

@media (max-width: 768px) {
	.mobile-menu-button {
		display: flex;
	}

	.header-inner {
		padding-inline: var(--vcsi-page-inline-padding);
		min-height: 0;
	}

	.header-left {
		gap: 0;
	}

	.nav-link {
		display: none;
	}

	.icon-button {
		width: 3.5rem;
		height: 3.5rem;
	}

	.site-logo {
		width: 3.5rem;
		height: 3.5rem;
	}

	.header-left {
		margin-top: 0.25rem;
	}
}
</style>