diff --git a/packages/outline-jump-nav/index.ts b/packages/outline-jump-nav/index.ts
new file mode 100644
index 000000000..5ebd7a44f
--- /dev/null
+++ b/packages/outline-jump-nav/index.ts
@@ -0,0 +1,3 @@
+export { OutlineJumpNav } from './src/outline-jump-nav';
+export type {} from './src/outline-jump-nav';
+export {} from './src/outline-jump-nav';
diff --git a/packages/outline-jump-nav/package.json b/packages/outline-jump-nav/package.json
new file mode 100644
index 000000000..fcb2d8b85
--- /dev/null
+++ b/packages/outline-jump-nav/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@phase2/outline-jump-nav",
+ "version": "0.1.0",
+ "description": "The Outline Components for the web jump navigation component",
+ "keywords": [
+ "outline",
+ "web-components",
+ "design system",
+ "jump-nav"
+ ],
+ "main": "index.ts",
+ "types": "index.ts",
+ "typings": "index.d.ts",
+ "files": [
+ "/dist/",
+ "!/dist/tsconfig.build.tsbuildinfo"
+ ],
+ "author": "Phase2 Technology",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/phase2/outline.git",
+ "directory": "packages/outline-jump-nav"
+ },
+ "license": "BSD-3-Clause",
+ "scripts": {
+ "build": "node ../../scripts/build.js",
+ "package": "yarn publish"
+ },
+ "dependencies": {
+ "@phase2/outline-core": "^0.1.0",
+ "lit": "^2.3.1",
+ "tslib": "^2.1.0"
+ },
+ "devDependencies": {},
+ "publishConfig": {
+ "access": "public"
+ },
+ "exports": {
+ ".": "./index.ts"
+ }
+}
diff --git a/packages/outline-jump-nav/src/outline-jump-nav.css b/packages/outline-jump-nav/src/outline-jump-nav.css
new file mode 100644
index 000000000..562a951ee
--- /dev/null
+++ b/packages/outline-jump-nav/src/outline-jump-nav.css
@@ -0,0 +1,78 @@
+:host {
+ z-index: 2;
+ display: block;
+ position: sticky;
+ top: var(--top-offset);
+}
+
+.outline-jump-nav {
+ display: flex;
+ width: 100%;
+ background-color: darkgrey;
+}
+
+/* Used for storybook */
+.outline-jump-nav--nav {
+ margin-bottom: 0;
+ padding: 0.5rem 0;
+}
+
+.outline-jump-nav--container {
+ width: 100%;
+ padding: 0.5rem 0;
+ @media (min-width: 768px) {
+ padding: 0;
+ }
+}
+
+.outline-jump-nav--list {
+ list-style: none;
+ display: flex;
+ padding: 0;
+ margin: 0;
+}
+
+.outline-jump-nav--item {
+ padding-right: 7%;
+}
+
+.outline-jump-nav--link {
+ display: flex;
+ position: relative;
+ font-weight: 500;
+ font-family: sans-serif;
+ text-decoration: none;
+ color: black;
+ align-self: center;
+ padding: 0.5rem 0;
+}
+
+.outline-jump-nav--link:focus-visible {
+ outline: darkblue 4px solid;
+ border-radius: 5px;
+}
+
+.active-jump::after {
+ content: '';
+ display: flex;
+ position: absolute;
+ width: 100%;
+ height: 4px;
+ background-color: darkblue;
+ bottom: 0;
+}
+
+.outline-jump-nav--select {
+ padding: 0.5rem 0.25rem;
+ margin: 0 auto;
+ background-color: lightskyblue;
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 1.25rem;
+ font-weight: 700;
+ border-radius: 8px;
+}
+
+.outline-jump-nav--label {
+ margin: 0 auto;
+ width: fit-content;
+}
diff --git a/packages/outline-jump-nav/src/outline-jump-nav.ts b/packages/outline-jump-nav/src/outline-jump-nav.ts
new file mode 100644
index 000000000..89fa7c951
--- /dev/null
+++ b/packages/outline-jump-nav/src/outline-jump-nav.ts
@@ -0,0 +1,469 @@
+import { CSSResultGroup, TemplateResult, html } from 'lit';
+import { OutlineElement } from '@phase2/outline-core';
+import { customElement, property, state, query } from 'lit/decorators.js';
+import componentStyles from './outline-jump-nav.css.lit';
+import { MobileController } from '@phase2/outline-core';
+
+export type OutlineJumpNavJumps = { [key: string]: string };
+export type OutlineJumpNavVisibility = { [key: string]: number };
+export const outlineJumpNavStatuses = ['loading', true, false] as const;
+export type OutlineJumpNavStatus = typeof outlineJumpNavStatuses[number];
+
+/**
+ * The OutlineJumpNav component
+ * @element outline-jump-nav
+ */
+@customElement('outline-jump-nav')
+export class OutlineJumpNav extends OutlineElement {
+ resizeObserver: ResizeObserver;
+ mobileController = new MobileController(this);
+ static styles: CSSResultGroup = [componentStyles];
+
+ /**
+ * ID of "active" element.
+ */
+ @property({ type: String })
+ isActive: string;
+
+ /**
+ * ID or classname of hero or any scrolling element jump nav may need to start below.
+ */
+ @property({ type: String })
+ hero: string;
+
+ /**
+ * ID or classname of navigation or any kind of sticky element the jump nav may need to remain positioned below.
+ */
+ @property({ type: String })
+ nav: string;
+
+ /**
+ * Jump target id and their % visible on screen.
+ */
+ @state()
+ visibility: OutlineJumpNavVisibility = {};
+
+ /**
+ * ID prefix for targeted sections.
+ */
+ @property({ type: String })
+ slug: string;
+
+ /**
+ * Expected link IDs and their titles.
+ */
+ @property({ type: Object })
+ jumps: OutlineJumpNavJumps = {};
+
+ /**
+ * Ref to the desktop ul element
+ */
+ @query('.outline-jump-nav--list')
+ ul: HTMLElement;
+
+ /**
+ * Ref to the mobile select element
+ */
+ @query('.outline-jump-nav--select')
+ select: HTMLElement;
+
+ /**
+ * Indicates if the component is first loading or if a toggle between desktop/mobile is required.
+ */
+ @property({ type: String || Boolean })
+ status: OutlineJumpNavStatus = 'loading';
+
+ /**
+ * Current height of the jump-nav. Used to determine true viewable space.
+ */
+ navOffset: number;
+
+ /**
+ * Current height of the main-nav. Used to determine true viewable space.
+ */
+ headerOffset: number;
+
+ /**
+ * Height of any hero on the page/the position that the jump-nav is supposed to switch to fixed/sticky position.
+ */
+ triggerFixedOffset: number;
+
+ /**
+ * Wether or not the nav is in 'fixed' position.
+ */
+ fixed: boolean;
+
+ /**
+ * If the users browser is set to 'prefers-reduced-motion' will prevent scrolling and cleanly jump to selected section.
+ */
+ preventScroll: boolean;
+
+ render(): TemplateResult {
+ return html`
+
+ ${this.mobileController.isMobile
+ ? this.mobileTemplate()
+ : this.desktopTemplate()}
+
+ `;
+ }
+
+ desktopTemplate() {
+ return html`
+
+ `;
+ }
+
+ mobileTemplate() {
+ return html`
+
+
+ `;
+ }
+
+ firstUpdated() {
+ this.initializeJumpsAndVisibility();
+ this.setOffsets();
+ this.toggleLinks();
+ this.setReduceMotion();
+ this.determineViewStatus();
+ this.resizeObserver.observe(this);
+ }
+
+ updated() {
+ this.setOffsets();
+ this.toggleLinks();
+ }
+
+ connectedCallback(): void {
+ super.connectedCallback();
+
+ this.resizeObserver = new ResizeObserver(() => {
+ this.setOffsets();
+ });
+
+ window.addEventListener('scroll', this.determineViewStatus);
+ window.addEventListener('scroll', this.toggleFixedPosition);
+ }
+
+ disconnectedCallback(): void {
+ window.removeEventListener('scroll', this.determineViewStatus);
+ window.removeEventListener('scroll', this.toggleFixedPosition);
+ this.resizeObserver.unobserve(this);
+ super.disconnectedCallback();
+ }
+
+ /**
+ * If user has prefers reduced motion set, prevents scrolling behavior and jumps the page to the link.
+ */
+ setReduceMotion() {
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ this.preventScroll = true;
+ }
+ }
+
+ /**
+ * Finds all elements with an id that begins with the slug, and creates the jumps object
+ * { element id: link text }
+ */
+ initializeJumpsAndVisibility() {
+ const targetedElements = document.querySelectorAll(`[id^="${this.slug}"]`);
+ targetedElements?.forEach(element => {
+ const name = element.id.split('--')[1];
+ this.jumps[`${element.id}`] = name;
+ });
+ this.initializeState();
+ }
+
+ /**
+ * Generates the visibility object.
+ */
+ initializeState() {
+ Object.keys(this.jumps).forEach(key => (this.visibility[key] = 0));
+ }
+
+ /**
+ * Sets the css var for the navs fixed position.
+ */
+ setTopVar(height: number) {
+ this.style.setProperty('--top-offset', `${height}px`);
+ }
+
+ /**
+ * Generates links from this.jumps object.
+ */
+ generateLinks() {
+ Object.entries(this.jumps).forEach(jump => {
+ const li = document.createElement('li');
+ const anchor = document.createElement('a');
+
+ li.classList.add('outline-jump-nav--item');
+ li.addEventListener('click', this.scrollHandler);
+
+ anchor.classList.add('outline-jump-nav--link');
+ anchor.id = `${jump[0]}-jump`;
+ anchor.setAttribute('href', `#${jump[0]}`);
+ anchor.setAttribute('aria-label', `scroll to ${jump[1]}`);
+
+ // insert your preferred string processing here
+ anchor.innerText = jump[1].toUpperCase();
+
+ li.appendChild(anchor);
+ this.ul.appendChild(li);
+ });
+ }
+
+ /**
+ * Generates mobile select options from this.jumps object.
+ */
+ generateMobileSelectOptions() {
+ Object.entries(this.jumps).forEach(jump => {
+ const option = document.createElement('option');
+ option.setAttribute('value', `${jump[0]}`);
+
+ // insert your preferred string processing here
+ option.innerText = `${jump[1]}`.toUpperCase();
+ this.select.appendChild(option);
+ });
+ }
+
+ /**
+ * Generates correct markup depending on screen width. Forces setActive to make sure all styles are toggled.
+ */
+ toggleLinks() {
+ if (this.mobileController.isMobile === this.status) {
+ return;
+ } else {
+ this.mobileController.isMobile
+ ? this.generateMobileSelectOptions()
+ : this.generateLinks();
+ this.status = this.mobileController.isMobile;
+ }
+ if (this.isActive) {
+ this.setActive(this.isActive, true);
+ }
+ }
+
+ /**
+ * On click/change "scroll handler" to initiate scrolling.
+ */
+ scrollHandler(e: Event) {
+ e.preventDefault();
+ const host = document.querySelector('outline-jump-nav') as OutlineJumpNav;
+ let target;
+ let targetHref;
+
+ if (host.mobileController.isMobile) {
+ target = e.target as HTMLOptionElement;
+ targetHref = `#${target.value}`;
+ }
+ if (!host.mobileController.isMobile) {
+ target = e.target as HTMLAnchorElement;
+ targetHref = target.getAttribute('href');
+ }
+
+ const scrollTarget = document.querySelector(`${targetHref}`) as HTMLElement;
+
+ if (scrollTarget) {
+ const correctedTop =
+ scrollTarget.offsetTop - (host.headerOffset + host.navOffset);
+ window.scroll({
+ top: correctedTop,
+ behavior: host.preventScroll ? 'auto' : 'smooth',
+ });
+ }
+ }
+
+ /**
+ * If an element has moved in/out of view, updates the state object and calls
+ * this.setActiveOnStateUpdate to determine if the active link should be changed.
+ */
+ updateState(id: string, percentage: number) {
+ if (this.visibility[id] !== percentage) {
+ this.visibility[id] = percentage;
+
+ this.setActiveOnStateUpdate();
+ }
+ }
+
+ /**
+ * Called when the component reaches/exceeds triggerFixedOffset point and applies/removes is-fixed class.
+ */
+ toggleFixedPosition() {
+ const host = document.querySelector('outline-jump-nav') as OutlineJumpNav;
+ const nav = this.shadowRoot?.querySelector(
+ '.outline-jump-nav'
+ ) as HTMLElement;
+
+ if (nav && window.scrollY >= host.triggerFixedOffset) {
+ nav.classList.add('is-fixed');
+ host.fixed = true;
+ }
+ if (nav && window.scrollY < host.triggerFixedOffset && host.fixed) {
+ nav.classList.remove('is-fixed');
+ host.fixed = false;
+ }
+ }
+
+ /**
+ * Determines headerOffset, topOffset, and triggerFixedOffset properties for use in calculations.
+ */
+ setOffsets() {
+ const header = document.querySelector(this.nav);
+ const headerHeight = header ? header.getBoundingClientRect().height : 0;
+ const hero = document.querySelector(this.hero);
+ const heroHeight = hero ? hero?.getBoundingClientRect().height : 0;
+ const nav = document.querySelector('outline-jump-nav');
+ this.navOffset = nav ? nav.getBoundingClientRect().height : 0;
+ this.headerOffset = headerHeight;
+ this.triggerFixedOffset = heroHeight;
+ this.setTopVar(this.headerOffset);
+ }
+
+ /**
+ * Passes all present elements with jump links to this.isInView.
+ */
+ determineViewStatus() {
+ const host = document.querySelector('outline-jump-nav') as OutlineJumpNav;
+ Object.keys(host.jumps).forEach(key => {
+ const element = document.querySelector(`#${key}`) as HTMLElement;
+ return host.isInView(element);
+ });
+ }
+
+ /**
+ * Takes in an element and returns an object comprised of several positioning values used in multiple methods.
+ */
+ getPositioningValues(el: HTMLElement) {
+ const windowTop = window.scrollY;
+ const windowBottom = windowTop + window.innerHeight;
+ const elRect = el.getBoundingClientRect();
+ const elTop = elRect.y + windowTop - (this.navOffset + this.headerOffset);
+ const elBottom = elRect.y + elRect.height + windowTop;
+ const elHeight = elRect.height;
+
+ return {
+ windowTop: windowTop,
+ windowBottom: windowBottom,
+ elTop: elTop,
+ elBottom: elBottom,
+ elHeight: elHeight,
+ };
+ }
+
+ /**
+ * Determines if an element is in view or not.
+ * If only partially in view passes the element id to this.elementXPercentInViewport.
+ */
+ isInView(el: HTMLElement) {
+ const { elTop, elBottom, windowTop, windowBottom } =
+ this.getPositioningValues(el);
+
+ const isVis = !(elTop > windowBottom || elBottom < windowTop);
+ const notVis = elTop > windowBottom || elBottom < windowTop;
+ const allVis =
+ (elTop >= windowTop && elBottom <= windowBottom) ||
+ (elTop < windowTop && elBottom > windowBottom);
+
+ if (notVis) {
+ return this.updateState(el.id, 0);
+ }
+
+ if (allVis) {
+ return this.updateState(el.id, 100);
+ }
+
+ if (isVis) {
+ return this.elementXPercentInViewport(el);
+ }
+ }
+
+ /**
+ * Determines the percentage of the element in view.
+ */
+ elementXPercentInViewport(el: HTMLElement) {
+ const { elTop, elBottom, elHeight, windowTop, windowBottom } =
+ this.getPositioningValues(el);
+
+ if (elBottom > windowBottom) {
+ const visPx = elHeight - (elBottom - windowBottom);
+ return this.updateState(el.id, Math.round((visPx / elHeight) * 100));
+ }
+ if (elTop < windowTop) {
+ const visPx = elHeight - (windowTop - elTop);
+ return this.updateState(el.id, Math.round((visPx / elHeight) * 100));
+ }
+ }
+
+ /**
+ * When the visibility state object is updated Compares % visible values and either passes the
+ * most visible elements ID to this.setActive or if more than one have the same value
+ * passes them to this.getTopPositions to determine which is the highest on the page.
+ */
+ setActiveOnStateUpdate() {
+ const hightestPercentageVisible = Math.max(
+ ...Object.values(this.visibility)
+ );
+
+ const mostVisibleElements = Object.entries(this.visibility).filter(
+ ele => ele[1] === hightestPercentageVisible
+ );
+
+ if (mostVisibleElements.length > 1) {
+ return this.getTopPositions(mostVisibleElements);
+ } else {
+ const id: string = mostVisibleElements[0][0];
+ return this.setActive(id);
+ }
+ }
+
+ /**
+ * Sorts through multiple elements that are the same percentage in view, and passes the id of the element highest on the page to setActive.
+ */
+ getTopPositions(ids: [string, number][]) {
+ const topPositions: { [key: string]: number } = {};
+ ids.map(ele => {
+ if (document.querySelector(`#${ele[0]}`)?.getBoundingClientRect().top) {
+ const id = ele[0] as string;
+ // @ts-expect-error - because-ts
+ return (topPositions[id] = document
+ .querySelector(`#${ele[0]}`)
+ ?.getBoundingClientRect().top);
+ } else {
+ return null;
+ }
+ });
+ const activeID = Object.keys(topPositions).find(
+ key => topPositions[key] === Math.min(...Object.values(topPositions))
+ )!;
+
+ return this.setActive(activeID);
+ }
+
+ /**
+ * Takes an ID and if not already the active ID, sets it as this.isActive, then handles the passing of the active-jump class to the correct link.
+ * The force argument is used when the component switches between mobile and desktop to make sure the active class is applied.
+ */
+ setActive(id: string, force?: boolean) {
+ if (this.isActive !== id || force === true) {
+ this.isActive = id;
+ this.shadowRoot
+ ?.querySelector(`.active-jump`)
+ ?.classList.remove('active-jump');
+ this.shadowRoot
+ ?.querySelector(`#${id}-jump`)
+ ?.classList.add('active-jump');
+
+ if (this.mobileController.isMobile) {
+ const selector = this.select as HTMLSelectElement;
+ selector.value = id;
+ }
+ }
+ }
+}
diff --git a/packages/outline-jump-nav/tsconfig.build.json b/packages/outline-jump-nav/tsconfig.build.json
new file mode 100644
index 000000000..ebc8e4b8e
--- /dev/null
+++ b/packages/outline-jump-nav/tsconfig.build.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "rootDir": ".",
+ "outDir": "./dist"
+ },
+ "include": ["index.ts", "src/**/*", "tests/**/*"],
+ "references": [{ "path": "../outline-core/tsconfig.build.json" }]
+}
diff --git a/packages/outline-storybook/stories/components/outline-jump-nav.stories.ts b/packages/outline-storybook/stories/components/outline-jump-nav.stories.ts
new file mode 100644
index 000000000..a5208bb51
--- /dev/null
+++ b/packages/outline-storybook/stories/components/outline-jump-nav.stories.ts
@@ -0,0 +1,82 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { html, TemplateResult } from 'lit';
+import '../../../outline-jump-nav/index';
+import '@phase2/outline-container';
+
+
+export default {
+ title: 'Navigation/JumpNav',
+ component: 'outline-jump-nav',
+ argTypes: {},
+ args: {
+ slug: 'outline-jump-nav--',
+ nav: '.pseudo-nav',
+ hero:'.pseudo-hero'
+ },
+ parameters: {
+ docs: {
+ description: {
+ component: `
+## The \`outline-jump-nav\` element
+# Props:
+* slug (\`string\`) **Required**: ID prefix for required for targeted sections. Must end with \`--\`. Example: \`outline-jump-nav--\`
+* nav (\`string\`): ID or class name query selector of navigation or any kind of sticky element the jump nav may need to remain positioned below. Example: \`.header-class\`
+* hero (\`string\`): ID or class name query selector of hero or any scrolling element jump nav may need to remain below when scrolled into view. Example: \`#hero-id\`
+
+# Usage:
+
Place on a page with multiple sections you wish to be able to scroll to. Each must have an id that follows this pattern [slug]--[link-tab-name].
+ \`Example:\` **outline-jump-nav--light-green**
+ The slug[outline-jump-nav--] identifies the element, and the text after the \`--\` of the slug sets the link name used on the jump-nav itself.
+ The "active" tab is set on scroll on the target element with the highest percentage of itself visible in the viewable area between the bottom of the jump-nav and the bottom of the screen.
+
+
+### Notes:
+
**Do not test here on the Docs page. There are too many scrolling windows.**