diff --git a/src/widgets/Navigation/model/types.ts b/src/widgets/Navigation/model/types.ts
new file mode 100644
index 0000000..e23d3a7
--- /dev/null
+++ b/src/widgets/Navigation/model/types.ts
@@ -0,0 +1,14 @@
+export type NavItem = {
+ /**
+ * Section HTML id for anchor scrolling
+ */
+ id: string
+ /**
+ * Display label
+ */
+ label: string
+ /**
+ * Display number prefix (e.g. "01")
+ */
+ number: string
+}
diff --git a/src/widgets/Navigation/ui/MobileNav.test.tsx b/src/widgets/Navigation/ui/MobileNav.test.tsx
new file mode 100644
index 0000000..2addc9d
--- /dev/null
+++ b/src/widgets/Navigation/ui/MobileNav.test.tsx
@@ -0,0 +1,46 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MobileNav } from './MobileNav'
+import type { NavItem } from '../model/types'
+
+const ITEMS: NavItem[] = [{ id: 'about', label: 'About', number: '01' }]
+
+describe('MobileNav', () => {
+ describe('rendering', () => {
+ it('renders title "allmy.work"', () => {
+ render()
+ expect(screen.getByText('allmy.work')).toBeInTheDocument()
+ })
+
+ it('renders toggle button with text "Menu" initially', () => {
+ render()
+ expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument()
+ })
+
+ it('menu items are hidden initially', () => {
+ render()
+ expect(screen.queryByRole('button', { name: /about/i })).not.toBeInTheDocument()
+ })
+ })
+
+ describe('interactions', () => {
+ it('click toggle shows item buttons and changes label to "Close"', async () => {
+ render()
+ await userEvent.click(screen.getByRole('button', { name: 'Menu' }))
+ expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
+ expect(screen.getByText('About')).toBeInTheDocument()
+ })
+
+ it('click item button closes the menu', async () => {
+ render()
+ await userEvent.click(screen.getByRole('button', { name: 'Menu' }))
+ // item button label contains number + label text; find by accessible name fragment
+ const itemBtn = screen.getAllByRole('button').find(b => b.textContent?.includes('About'))
+ expect(itemBtn).toBeDefined()
+ await userEvent.click(itemBtn!)
+ expect(screen.queryByText('Close')).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/widgets/Navigation/ui/MobileNav.tsx b/src/widgets/Navigation/ui/MobileNav.tsx
new file mode 100644
index 0000000..0940ed8
--- /dev/null
+++ b/src/widgets/Navigation/ui/MobileNav.tsx
@@ -0,0 +1,66 @@
+'use client'
+
+import { useState } from 'react'
+import { cn } from '$shared/lib'
+import type { NavItem } from '../model/types'
+
+interface Props {
+ /**
+ * Navigation items to render
+ */
+ items: NavItem[]
+}
+
+/**
+ * Mobile navigation overlay, hidden on lg+ screens.
+ */
+export function MobileNav({ items }: Props) {
+ const [isOpen, setIsOpen] = useState(false)
+
+ /**
+ * Scrolls to the section by id with a 100px offset, then closes the menu.
+ */
+ function scrollToSection(id: string) {
+ const el = document.getElementById(id)
+ if (el) {
+ const top = el.getBoundingClientRect().top + window.scrollY - 100
+ window.scrollTo({ top, behavior: 'smooth' })
+ }
+ setIsOpen(false)
+ }
+
+ return (
+
+
+
allmy.work
+
+
+ {isOpen && (
+
+ {items.map(item => (
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/widgets/Navigation/ui/SidebarNav.test.tsx b/src/widgets/Navigation/ui/SidebarNav.test.tsx
new file mode 100644
index 0000000..d9f9784
--- /dev/null
+++ b/src/widgets/Navigation/ui/SidebarNav.test.tsx
@@ -0,0 +1,62 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { SidebarNav } from './SidebarNav'
+import type { NavItem } from '../model/types'
+
+const ITEMS: NavItem[] = [
+ { id: 'bio', label: 'Bio', number: '01' },
+ { id: 'work', label: 'Work', number: '02' },
+]
+
+beforeEach(() => {
+ global.IntersectionObserver = vi.fn(function () {
+ return {
+ observe: vi.fn(),
+ disconnect: vi.fn(),
+ unobserve: vi.fn(),
+ }
+ }) as unknown as typeof IntersectionObserver
+})
+
+describe('SidebarNav', () => {
+ describe('rendering', () => {
+ it('renders a nav element', () => {
+ render()
+ expect(screen.getByRole('navigation')).toBeInTheDocument()
+ })
+
+ it('renders "Index" heading', () => {
+ render()
+ expect(screen.getByText('Index')).toBeInTheDocument()
+ })
+
+ it('renders "Digital Monograph" subtitle', () => {
+ render()
+ expect(screen.getByText('Digital Monograph')).toBeInTheDocument()
+ })
+
+ it('renders each item label and number', () => {
+ render()
+ expect(screen.getByText('Bio')).toBeInTheDocument()
+ expect(screen.getByText('01')).toBeInTheDocument()
+ expect(screen.getByText('Work')).toBeInTheDocument()
+ expect(screen.getByText('02')).toBeInTheDocument()
+ })
+
+ it('renders "Quick Links" section', () => {
+ render()
+ expect(screen.getByText('Quick Links')).toBeInTheDocument()
+ })
+
+ it('renders Email quick link', () => {
+ render()
+ expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument()
+ })
+
+ it('renders a button for each item', () => {
+ render()
+ const buttons = screen.getAllByRole('button')
+ expect(buttons.length).toBeGreaterThanOrEqual(ITEMS.length)
+ })
+ })
+})
diff --git a/src/widgets/Navigation/ui/SidebarNav.tsx b/src/widgets/Navigation/ui/SidebarNav.tsx
new file mode 100644
index 0000000..9962b03
--- /dev/null
+++ b/src/widgets/Navigation/ui/SidebarNav.tsx
@@ -0,0 +1,94 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { cn } from '$shared/lib'
+import type { NavItem } from '../model/types'
+
+interface Props {
+ /**
+ * Navigation items to render
+ */
+ items: NavItem[]
+}
+
+/**
+ * Fixed sidebar navigation, visible on lg+ screens.
+ */
+export function SidebarNav({ items }: Props) {
+ const [activeSection, setActiveSection] = useState('bio')
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ entries => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ setActiveSection(entry.target.id)
+ }
+ })
+ },
+ { rootMargin: '-20% 0px -70% 0px', threshold: 0 },
+ )
+
+ items.forEach(item => {
+ const el = document.getElementById(item.id)
+ if (el) observer.observe(el)
+ })
+
+ return () => observer.disconnect()
+ }, [items])
+
+ /**
+ * Scrolls to the section by id with a 40px offset.
+ */
+ function scrollToSection(id: string) {
+ const el = document.getElementById(id)
+ if (el) {
+ const top = el.getBoundingClientRect().top + window.scrollY - 40
+ window.scrollTo({ top, behavior: 'smooth' })
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/src/widgets/Navigation/ui/UtilityBar.test.tsx b/src/widgets/Navigation/ui/UtilityBar.test.tsx
new file mode 100644
index 0000000..5e4b99f
--- /dev/null
+++ b/src/widgets/Navigation/ui/UtilityBar.test.tsx
@@ -0,0 +1,30 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { UtilityBar } from './UtilityBar'
+
+describe('UtilityBar', () => {
+ describe('rendering', () => {
+ it('renders "Contact" label', () => {
+ render()
+ expect(screen.getByText('Contact')).toBeInTheDocument()
+ })
+
+ it('renders email link with correct href', () => {
+ render()
+ const link = screen.getByRole('link', { name: 'hello@allmy.work' })
+ expect(link).toBeInTheDocument()
+ expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work')
+ })
+
+ it('renders "Download CV" button', () => {
+ render()
+ expect(screen.getByRole('button', { name: /download cv/i })).toBeInTheDocument()
+ })
+
+ it('Download CV button has primary variant class', () => {
+ render()
+ const btn = screen.getByRole('button', { name: /download cv/i })
+ expect(btn).toHaveClass('bg-burnt-oxide')
+ })
+ })
+})
diff --git a/src/widgets/Navigation/ui/UtilityBar.tsx b/src/widgets/Navigation/ui/UtilityBar.tsx
new file mode 100644
index 0000000..a66bb40
--- /dev/null
+++ b/src/widgets/Navigation/ui/UtilityBar.tsx
@@ -0,0 +1,34 @@
+'use client'
+
+import { Button } from '$shared/ui'
+
+/**
+ * Fixed bottom utility bar with contact info and CV download.
+ */
+export function UtilityBar() {
+ /**
+ * Handles CV download action.
+ */
+ function handleDownloadCV() {
+ console.log('Downloading CV...')
+ }
+
+ return (
+
+ )
+}